diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..f66f76b3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=lf + +*.cer binary +*.ico binary +*.msix binary +*.pfx binary +*.png binary +*.zip binary diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index 63d9a906..b5ea9f1b 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -42,6 +42,9 @@ jobs: - name: Resolve dependencies run: flutter pub get + - name: Generate localizations + run: flutter gen-l10n + - name: Generate Drift code run: dart run build_runner build --delete-conflicting-outputs --force-jit @@ -49,11 +52,14 @@ jobs: run: git diff --exit-code - name: Check formatting - run: dart format --set-exit-if-changed . + run: dart format --output=none --set-exit-if-changed . - name: Analyze run: flutter analyze + - name: Check platform boundaries + run: dart run tool/check_platform_boundaries.dart + - name: Test run: flutter test @@ -73,7 +79,7 @@ jobs: if [ -n "$MICROSOFT_OAUTH_CLIENT_ID" ]; then args+=("--dart-define=MICROSOFT_OAUTH_CLIENT_ID=$MICROSOFT_OAUTH_CLIENT_ID") fi - flutter build linux "${args[@]}" + flutter build linux -t lib/main_linux.dart "${args[@]}" - name: Build strict Snap from the release bundle id: snapcraft diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml new file mode 100644 index 00000000..bc9ec29d --- /dev/null +++ b/.github/workflows/flutter-windows.yml @@ -0,0 +1,325 @@ +name: Flutter Windows + +on: + workflow_dispatch: + pull_request: + branches: [main] + push: + branches: + - main + - 'Release/**' + +jobs: + build: + runs-on: windows-latest + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: '3.44.4' + cache: true + + - name: Enable Windows desktop + shell: pwsh + run: flutter config --enable-windows-desktop + + - name: Install pinned Pester + shell: pwsh + run: >- + Install-Module Pester -RequiredVersion 5.7.1 + -Scope CurrentUser -Force -AllowClobber + + - name: Create explicitly non-production CI identity + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path build/windows | Out-Null + @{ + production = $false + identityName = 'BusyStack.BusyMax.CI' + publisher = 'CN=BusyMax CI Package' + publisherDisplayName = 'BusyMax CI' + msixVersion = '1.0.0.0' + previousMsixVersion = '' + privacyPolicyUrl = 'https://busystack.org/privacy' + supportUrl = 'https://busystack.org/support' + homepageUrl = 'https://busystack.org' + googleOAuthClientId = 'busymax-ci-google-client-id' + googleOAuthClientSecret = '' + microsoftOAuthClientId = 'busymax-ci-microsoft-client-id' + microsoftOAuthAuthorityTenant = 'common' + fakeData = $false + developmentBackend = $false + } | ConvertTo-Json | Set-Content ` + -LiteralPath build/windows/store-ci.json -Encoding utf8NoBOM + + - name: Verify x64 Windows build host + shell: pwsh + run: | + if ($env:OS -ne 'Windows_NT' -or + -not [Environment]::Is64BitOperatingSystem) { + throw 'BusyMax Windows builds require 64-bit Windows.' + } + + - name: Verify pinned Flutter version + shell: pwsh + run: | + $version = (flutter --version --machine | + ConvertFrom-Json).frameworkVersion + if ($version -ne '3.44.4') { + throw "BusyMax requires Flutter 3.44.4; found $version." + } + + - name: Verify Visual Studio C++ toolchain + shell: pwsh + run: | + $vswhere = Join-Path ${env:ProgramFiles(x86)} ` + 'Microsoft Visual Studio\Installer\vswhere.exe' + $installation = & $vswhere -latest -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property installationPath + if ([string]::IsNullOrWhiteSpace($installation)) { + throw 'Visual Studio C++ x64 desktop tools were not found.' + } + + - name: Verify Windows SDK packaging tools + shell: pwsh + run: | + . ./tool/windows/common.ps1 + $kitsBin = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + $sdk = Get-BusyMaxWindowsSdkDirectory -KitsBin $kitsBin + if ($null -eq $sdk) { + throw 'Windows SDK 10.0.26100.0 or newer is required.' + } + foreach ($tool in @('makeappx.exe', 'signtool.exe')) { + if (-not (Test-Path -LiteralPath ` + (Join-Path $sdk.FullName "x64\$tool"))) { + throw "$tool was not found in Windows SDK $($sdk.Name)." + } + } + + - name: Verify combined Windows build prerequisites + shell: pwsh + run: | + New-Item -ItemType Directory -Force ` + -Path build/windows/test-results | Out-Null + $environment = ./tool/windows/check_prerequisites.ps1 + $environment | ConvertTo-Json | Set-Content ` + -LiteralPath build/windows/test-results/windows-environment.json ` + -Encoding utf8NoBOM + $environment | Format-List + + - name: Validate non-production CI identity + shell: pwsh + run: >- + ./tool/windows/validate_release_config.ps1 + -ConfigPath build/windows/store-ci.json + -Ci | Format-List + + - name: Run pinned Pester contract tests + shell: pwsh + run: >- + ./tool/windows/build_release.ps1 + -ConfigPath build/windows/store-ci.json + -Ci -Stage PesterTests + + - name: Restore dependencies and generate sources + shell: pwsh + run: >- + ./tool/windows/build_release.ps1 + -ConfigPath build/windows/store-ci.json + -Ci -Stage SourceGeneration + + - name: Check formatting, analysis, and platform boundaries + shell: pwsh + run: >- + ./tool/windows/build_release.ps1 + -ConfigPath build/windows/store-ci.json + -Ci -Stage StaticAnalysis + + - name: Run Dart and Flutter tests + id: flutter-tests + shell: pwsh + run: >- + ./tool/windows/build_release.ps1 + -ConfigPath build/windows/store-ci.json + -Ci -Stage FlutterTests + + - name: Report failing Flutter tests + if: ${{ failure() && steps.flutter-tests.conclusion == 'failure' }} + shell: pwsh + run: | + $report = 'build/windows/test-results/flutter-tests.jsonl' + if (-not (Test-Path -LiteralPath $report -PathType Leaf)) { + Write-Host '::error title=Windows Flutter tests failed::The machine test report was not created.' + exit 0 + } + $names = @{} + $failedIds = [System.Collections.Generic.HashSet[int]]::new() + $runnerErrors = [System.Collections.Generic.List[string]]::new() + $doneSuccess = $null + foreach ($line in Get-Content -LiteralPath $report) { + try { $event = $line | ConvertFrom-Json } catch { continue } + if ($event.type -eq 'testStart') { + $names[[int]$event.test.id] = [string]$event.test.name + } + if ($event.type -eq 'testDone' -and + $event.result -notin @('success', 'skipped')) { + $failedIds.Add([int]$event.testID) | Out-Null + } + if ($event.type -eq 'error') { + if ($null -ne $event.testID) { + $failedIds.Add([int]$event.testID) | Out-Null + } + $summary = ([string]$event.error -split '\r?\n', 2)[0] + if (-not [string]::IsNullOrWhiteSpace($summary)) { + $runnerErrors.Add($summary) | Out-Null + } + } + if ($event.type -eq 'done') { + $doneSuccess = $event.success + } + } + if ($failedIds.Count -eq 0) { + $summary = "No failing test ID was present; machine success=$doneSuccess." + if ($runnerErrors.Count -gt 0) { + $summary += " Runner error: $($runnerErrors[0])" + } + $safeSummary = $summary.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') + Write-Host "::error title=Windows Flutter tests failed::$safeSummary" + } + foreach ($testId in $failedIds) { + $name = if ($names.ContainsKey($testId)) { + $names[$testId] + } else { + "Unknown test ID $testId" + } + $safeName = $name.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') + Write-Host "::error title=Windows Flutter test failed::$safeName" + } + + - name: Compile Windows x64 release + id: windows-compile + shell: pwsh + run: >- + ./tool/windows/build_release.ps1 + -ConfigPath build/windows/store-ci.json + -Ci -Stage WindowsCompile + + - name: Report Windows compiler failures + if: ${{ failure() && steps.windows-compile.conclusion == 'failure' }} + shell: pwsh + run: | + $log = 'build/windows/test-results/windows-build.log' + if (-not (Test-Path -LiteralPath $log -PathType Leaf)) { + Write-Host '::error title=Windows release compilation failed::The compiler log was not created.' + exit 0 + } + $diagnostics = @(Get-Content -LiteralPath $log | + Where-Object { + $_ -match '(?i)(cmake error|fatal error|error C\d+|LNK\d+|ninja:.*failed|error:)' + } | Select-Object -Unique -First 20) + if ($diagnostics.Count -eq 0) { + $diagnostics = @(Get-Content -LiteralPath $log | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Last 1) + } + foreach ($diagnostic in $diagnostics) { + $safe = ([string]$diagnostic).Replace('%', '%25') + $safe = $safe.Replace("`r", '%0D').Replace("`n", '%0A') + if ($safe.Length -gt 1000) { $safe = $safe.Substring(0, 1000) } + Write-Host "::error title=Windows release compilation failed::$safe" + } + + - name: Run native C++ tests + id: native-tests + shell: pwsh + run: >- + ./tool/windows/build_release.ps1 + -ConfigPath build/windows/store-ci.json + -Ci -Stage NativeTests + + - name: Report native C++ test failures + if: ${{ failure() && steps.native-tests.conclusion == 'failure' }} + shell: pwsh + run: | + $logs = @(Get-ChildItem -Path 'build/windows/test-results' ` + -Filter 'native-*.log' -File -ErrorAction SilentlyContinue) + if ($logs.Count -eq 0) { + Write-Host '::error title=Windows native tests failed::No native-test log was created.' + exit 0 + } + $diagnostics = @($logs | ForEach-Object { + Get-Content -LiteralPath $_.FullName + } | Where-Object { + $_ -match '(?i)(cmake error|fatal error|error C\d+|LNK\d+|ninja:.*failed|FAILED|tests failed|exception)' + } | Select-Object -Unique -First 20) + if ($diagnostics.Count -eq 0) { + $diagnostics = @($logs | ForEach-Object { + Get-Content -LiteralPath $_.FullName + } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Last 10) + } + foreach ($diagnostic in $diagnostics) { + $safe = ([string]$diagnostic).Replace('%', '%25') + $safe = $safe.Replace("`r", '%0D').Replace("`n", '%0A') + if ($safe.Length -gt 1000) { $safe = $safe.Substring(0, 1000) } + Write-Host "::error title=Windows native tests failed::$safe" + } + + - name: Pack and inspect exact MSIX + id: package + shell: pwsh + run: >- + ./tool/windows/build_release.ps1 + -ConfigPath build/windows/store-ci.json + -Ci -Stage Package + + - name: Report exact-MSIX packaging failures + if: ${{ failure() && steps.package.conclusion == 'failure' }} + shell: pwsh + run: | + $log = 'build/windows/test-results/package-store.log' + if (-not (Test-Path -LiteralPath $log -PathType Leaf)) { + Write-Host '::error title=Exact MSIX packaging failed::The package log was not created.' + exit 0 + } + $diagnostics = @(Get-Content -LiteralPath $log | + Where-Object { + $_ -match '(?i)(makeappx.*error|error APPX|throw|exception|failed|missing|prohibited|unexpected|does not|differs|invalid|cannot|could not|refusing)' + } | Select-Object -Unique -First 20) + if ($diagnostics.Count -eq 0) { + $diagnostics = @(Get-Content -LiteralPath $log | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Last 10) + } + foreach ($diagnostic in $diagnostics) { + $safe = ([string]$diagnostic).Replace('%', '%25') + $safe = $safe.Replace("`r", '%0D').Replace("`n", '%0A') + if ($safe.Length -gt 1000) { $safe = $safe.Substring(0, 1000) } + Write-Host "::error title=Exact MSIX packaging failed::$safe" + } + + - name: Upload Windows test reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: busymax-windows-x64-test-reports + path: build/windows/test-results/** + if-no-files-found: warn + + - name: Upload unsigned CI MSIX and package evidence + if: success() + uses: actions/upload-artifact@v7 + with: + name: busymax-windows-x64-ci + path: | + build/windows/store/*.msix + build/windows/store/staging/AppxManifest.xml + build/windows/store/*inventory.json + build/windows/store/*metadata.json + if-no-files-found: error diff --git a/.gitignore b/.gitignore index eb0e8ec8..036b4cbb 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,14 @@ app.*.map.json .snap-local/ # Local Snap packages contain compiled build-time configuration. -/*.snap +*.snap +**/*.snap + +# Local Windows Store identity, certificates, and signing material. +config/windows_store.local.json +*.pfx +*.cer +*.p12 +*.pem +*.key +*.crt diff --git a/.metadata b/.metadata index a30de0e2..da084209 100644 --- a/.metadata +++ b/.metadata @@ -18,6 +18,9 @@ migration: - platform: linux create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + - platform: windows + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 # User provided section diff --git a/README.md b/README.md index 14ef73af..0803d1c7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # BusyMax -BusyMax is a Linux desktop calendar and task manager built with Flutter. +BusyMax is a Linux and Windows desktop calendar and task manager built with +Flutter. -It brings calendar events and tasks into a native-feeling Linux desktop -interface. BusyMax connects directly to Google, Microsoft, Apple iCloud -Calendar, and Nextcloud. Apple Reminders is not supported. +It brings calendar events and tasks into a native-feeling desktop interface: +Yaru with the existing GTK integrations on Linux, and Fluent UI with the native +title bar on Windows 11. BusyMax connects directly to Google, Microsoft, Apple +iCloud Calendar, and Nextcloud. Apple Reminders is not supported. [![busymax](https://snapcraft.io/busymax/badge.svg)](https://snapcraft.io/busymax) @@ -20,7 +22,7 @@ Calendar, and Nextcloud. Apple Reminders is not supported. ## Highlights -- Linux desktop app built with Flutter. +- First-class Linux Yaru and Windows 11 Fluent desktop applications. - Calendar views for day, week, month, year, and agenda planning. - Task creation with lists, start/due dates, reminders, repeat options, subtasks, status, progress, priority, categories, location, and URL. @@ -88,6 +90,7 @@ Calendar, and Nextcloud. Apple Reminders is not supported. ## Prerequisites - [Flutter SDK](https://docs.flutter.dev/install) +- Flutter is pinned to 3.44.4 for both platforms. - GTK 3 and libhandy development packages (`libgtk-3-dev` and `libhandy-1-dev` on Ubuntu/Debian) - `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`; see @@ -101,26 +104,31 @@ Apple and Nextcloud do not require compile-time client credentials: - [Nextcloud setup](docs/nextcloud_setup.md) requires an HTTPS server and completes authorization in the default browser. -## Run locally +Windows targets Windows 11 24H2 or newer on x64 only. See +[Windows development](docs/windows_development.md) for Visual Studio and SDK +requirements, exact development commands, and the source-side validation +workflow. + +## Run locally on Linux Register the development launcher once so GNOME can associate BusyMax's native Wayland windows with its desktop icon. The helper is idempotent and defaults to the Flutter debug bundle: ```bash -tools/install_linux_dev_desktop.sh +tool/install_linux_dev_desktop.sh ``` Then run BusyMax normally: ```bash -flutter run -d linux \ +flutter run -d linux -t lib/main_linux.dart \ --dart-define=GOOGLE_OAUTH_CLIENT_ID= \ --dart-define=GOOGLE_OAUTH_CLIENT_SECRET= \ --dart-define=MICROSOFT_OAUTH_CLIENT_ID= ``` -Use `tools/install_linux_dev_desktop.sh --uninstall` to remove the development +Use `tool/install_linux_dev_desktop.sh --uninstall` to remove the development launcher. Remove it before testing an installed Snap so the user-level launcher does not take precedence; packaged Snaps register their own launcher. @@ -129,7 +137,7 @@ does not take precedence; packaged Snaps register their own launcher. The native **Send feedback** form in the About dialog sends JSON to `POST https://busystack.org/api/feedback`. Every submission contains a new submission UUID, the application identifier `busymax`, the application version -and build number, the `linux` platform identifier, category, subject, message, +and build number, the runtime platform identifier, category, subject, message, and optional reply email. A successful response has this form: ```json @@ -137,9 +145,9 @@ and optional reply email. A successful response has this form: ``` The optional technical-details checkbox is off by default. When the user -explicitly enables it, BusyMax adds only the Linux operating-system version and +explicitly enables it, BusyMax adds only the operating-system version and application locale. BusyMax does not attach logs, account or calendar data, -file names, screenshots, environment variables, or other diagnostics. +file names, screenshots, environment variables, tokens, or other diagnostics. For local website development, override the endpoint through the existing compile-time configuration mechanism: @@ -166,3 +174,16 @@ and iCloud integration tests. See [Snap Build and Beta Release](docs/beta_snap_release.md) for OAuth build configuration, canonical Snapcraft packaging, local installation, artifact verification, Store review, and beta release instructions. + +## Windows architecture and packaging + +Windows uses the separate `lib/main_windows.dart` entrypoint and a Fluent UI +composition. Linux continues to use `lib/main_linux.dart`, Yaru, GTK, the XDG +tray, and the existing Snap package. The Windows Store MSIX workflow is local +and CI-only; it never deploys an artifact. + +- [Windows architecture](docs/windows_architecture.md) +- [Windows UI](docs/windows_ui.md) +- [Windows packaging](docs/windows_packaging.md) +- [Windows release checklist](docs/windows_release_checklist.md) +- [Privacy and data map](docs/privacy_data_map.md) diff --git a/analysis_options.yaml b/analysis_options.yaml index dd434b29..f6989f7f 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -3,7 +3,7 @@ include: package:flutter_lints/flutter.yaml analyzer: exclude: - build/** - - tools/google_tasks_discovery/cache/** + - tool/google_tasks_discovery/cache/** linter: rules: diff --git a/assets/windows/busymax_tray.ico b/assets/windows/busymax_tray.ico new file mode 100644 index 00000000..0dc7487a Binary files /dev/null and b/assets/windows/busymax_tray.ico differ diff --git a/assets/windows/busymax_tray_offline.ico b/assets/windows/busymax_tray_offline.ico new file mode 100644 index 00000000..a35e35c8 Binary files /dev/null and b/assets/windows/busymax_tray_offline.ico differ diff --git a/config/windows_store.example.json b/config/windows_store.example.json new file mode 100644 index 00000000..a83ee243 --- /dev/null +++ b/config/windows_store.example.json @@ -0,0 +1,17 @@ +{ + "production": true, + "identityName": "REPLACE_WITH_PARTNER_CENTER_IDENTITY_NAME", + "publisher": "CN=REPLACE_WITH_PARTNER_CENTER_PUBLISHER", + "publisherDisplayName": "REPLACE_WITH_PUBLISHER_DISPLAY_NAME", + "msixVersion": "REPLACE_WITH_FOUR_PART_VERSION", + "previousMsixVersion": "", + "privacyPolicyUrl": "https://REPLACE_WITH_PRIVACY_POLICY_URL", + "supportUrl": "https://REPLACE_WITH_SUPPORT_URL", + "homepageUrl": "https://busystack.org", + "googleOAuthClientId": "REPLACE_WITH_GOOGLE_DESKTOP_CLIENT_ID", + "googleOAuthClientSecret": "REPLACE_WITH_GOOGLE_DESKTOP_CLIENT_SECRET", + "microsoftOAuthClientId": "REPLACE_WITH_MICROSOFT_PUBLIC_CLIENT_ID", + "microsoftOAuthAuthorityTenant": "common", + "fakeData": false, + "developmentBackend": false +} diff --git a/docs/beta_snap_release.md b/docs/beta_snap_release.md index affb0537..196f44c0 100644 --- a/docs/beta_snap_release.md +++ b/docs/beta_snap_release.md @@ -76,7 +76,7 @@ For a local scaffold smoke build instead, first quit every running BusyMax instance, including its tray process and any development build: ```bash -./tools/build_install_snap_local.sh \ +./tool/build_install_snap_local.sh \ --dart-define-from-file .snap-local/busymax-dart-defines.json ``` diff --git a/docs/privacy_data_map.md b/docs/privacy_data_map.md new file mode 100644 index 00000000..4a515d6d --- /dev/null +++ b/docs/privacy_data_map.md @@ -0,0 +1,36 @@ +# Privacy and data map + +BusyMax processes calendar, event, task, account, reminder, and synchronization +data to provide its user-requested desktop functions. It does not add telemetry +or analytics. + +| Data | Purpose | Local handling | Network destination | +|---|---|---|---| +| OAuth access/refresh tokens and DAV app passwords | Authenticate selected accounts | Windows secure-storage backend or Linux portal/keyring backend; removed on sign-out/account removal as applicable | Google, Microsoft, Apple iCloud, or the user's Nextcloud server over HTTPS | +| OAuth authorization code, PKCE verifier, and state | Complete browser/loopback sign-in | Short-lived in memory; callback binds only to loopback; never logged | Selected provider token endpoint | +| Calendar/event/task/source identifiers and content | Display, edit, synchronize, recur, import/export, and work offline | Drift database in the user-writable application-support directory | Only the account provider selected by the user | +| Reminder schedule rows | Deliver in-process reminders and make actions idempotent | Drift; stopped by explicit Quit | No new scheduling service; Windows toast receives stable opaque IDs only | +| `.ics` files and export destinations | User-requested import review/export | Read or written only through explicit activation/file selection; external activation never silently imports | None unless the user later synchronizes confirmed content | +| WebCal URI | User-requested calendar subscription | Validated and confirmed before subscription | The confirmed HTTPS/WebCal calendar endpoint | +| Local settings | Theme, locale, notification, tray, and startup preferences | User-writable settings storage | None | +| Feedback category, subject, message, optional reply email, random submission ID, app/version/build/platform | Submit feedback requested by the user | Prepared only for an explicit submission | Configured BusyStack feedback HTTPS endpoint | +| Optional feedback technical details | Diagnose explicitly reported issues | OS version and application locale only; checkbox is off by default | Same feedback endpoint when the user opts in | + +Feedback never silently attaches logs, account/provider content, calendar/task +content, filenames, screenshots, environment variables, stable device IDs, +tokens, secrets, or activation payloads. + +Windows package declarations are limited to internet client and full-trust +desktop execution needed by Flutter Win32. BusyMax does not request location, +microphone, webcam, contacts, broad-filesystem, or private-network capability. +File import/export uses the selected file or manifest-declared `.ics` +association. Startup uses the user-controlled package StartupTask. + +The Windows privacy-policy and support URLs are build inputs. Production +validation rejects missing, placeholder, or non-HTTPS values. App disclosures +and the external privacy page must be reviewed whenever provider calls, +feedback fields, storage behavior, or package capabilities change. + +Uninstall and package-update retention behavior must be verified with the +installed MSIX because Windows owns package data lifecycle. BusyMax never writes +the database or credentials into the immutable MSIX installation directory. diff --git a/docs/provider_support_matrix.md b/docs/provider_support_matrix.md index 96d1d2f4..af73d5a0 100644 --- a/docs/provider_support_matrix.md +++ b/docs/provider_support_matrix.md @@ -11,6 +11,20 @@ access from a provider name. | Microsoft | Microsoft Calendar | Microsoft To Do | OAuth public client | | Apple iCloud | iCloud Calendar | Not supported; Apple Reminders is not supported | Apple app-specific password over HTTPS | | Nextcloud | `VEVENT` collections | `VTODO` collections | Login Flow v2 in the default browser | +| WebCal | Read-only calendar subscriptions | Not supported | Subscription URL stored as a secret | + +## Collection creation + +| Capability | Google | Microsoft | Nextcloud | Apple iCloud | WebCal | +|---|---|---|---|---|---| +| Create calendar | Yes | Yes | Yes | No | No | +| Create task list | Yes | Yes | Yes | No | No | + +Nextcloud event calendars are created remotely with CalDAV `MKCALENDAR` and +an event-only (`VEVENT`) component set. Nextcloud task lists continue to use +their existing `VTODO` collection implementation. This is Nextcloud-specific; +BusyMax does not claim generic CalDAV collection creation. Apple collection +creation remains unsupported, and WebCal subscriptions remain read-only. ## CalDAV synchronization @@ -24,7 +38,8 @@ access from a provider name. | Recurrence rules and exceptions | Supported | Supported | Supported; see the editable subset below | | `VALARM` preservation | Supported | Supported | Supported | | Read-only and shared collections | Supported | Supported | Supported | -| Collection create, rename, delete, or unshare | Not supported | Not supported | Supported online when permitted | +| Collection creation | Not supported | Supported online through `MKCALENDAR` | Supported online when permitted | +| Collection rename, delete, or unshare | Not supported | Not supported | Supported online when permitted | | Collection color or order editing | Not supported | Not supported | Not supported | | Cross-list task moves | Not applicable | Not applicable | Supported between writable Nextcloud task collections | | Clear completed tasks | Not applicable | Not applicable | Supported | diff --git a/docs/windows_architecture.md b/docs/windows_architecture.md new file mode 100644 index 00000000..96e29876 --- /dev/null +++ b/docs/windows_architecture.md @@ -0,0 +1,119 @@ +# Windows architecture + +BusyMax has one domain and data model with two desktop compositions. + +```text +domain / providers / repositories / Drift / sync / OAuth / recurrence + | + common desktop interfaces + / \ + Linux composition Windows composition + MaterialApp.router FluentApp.router + Yaru + GTK/DBus/XDG Fluent + Win32/MSIX +``` + +`lib/main.dart` delegates to `lib/main_linux.dart` to preserve the existing +Linux default. Windows build commands explicitly select `lib/main_windows.dart`. +`LinuxBusyMaxApp` retains Material/Yaru, Ubuntu localization delegates, and the +GTK header-bar integration. `WindowsBusyMaxApp` owns the Fluent root and does +not import the Linux composition. + +## Dependency boundaries + +Common application, domain, repository, synchronization, OAuth, recurrence, +database, and scheduling code can depend on Flutter primitives and BusyMax +types. It cannot import Yaru, Fluent UI, Ubuntu/GTK/DBus/XDG implementations, or +Windows runner adapters. Platform code translates native/package types into +BusyMax-owned models at the boundary. + +The injected desktop interfaces are: + +- `DesktopWindowService` +- `DesktopTrayService` +- `DesktopAutostartService` +- `DesktopNotificationBackend` +- `DesktopActivationService` +- `SystemAppearanceSource` +- `LocalTimeZoneSource` + +The automated policy is implemented by `tool/check_platform_boundaries.dart` +and runs in Linux and Windows CI. + +`tray_manager` 0.5.3 and `flutter_timezone` 5.1.0 are pinned through committed +Windows-only packaging of their upstream plugins. This prevents Flutter's +generated Linux plugin graph from registering a second AppIndicator tray or +replacing the existing Linux timezone path. The boundary check requires both +plugins only in the Windows graph. Their Dart APIs, Windows sources, versions, +and upstream licenses are preserved. + +## Window and single instance + +The Win32 runner uses the standard Windows title bar, caption buttons, system +menu, maximize/restore behavior, and per-monitor-v2 DPI awareness. Flutter +content begins below the non-client title bar. Minimum tracking size is +900x600 logical pixels and the initial content size is 1280x800. + +One process per Windows user owns a named mutex containing that user's SID. +Later processes validate a bounded activation, connect to a local named pipe +whose DACL grants only the current SID, forward a length-prefixed UTF-8 message, +and wait for a one-byte acceptance acknowledgment before exiting. Connection +retries cover the bounded interval between the primary acquiring its mutex and +creating its first pipe instance. The secondary allows the primary process to +take foreground focus and never starts Dart or Drift. The primary acknowledges +only after it validates and queues the activation until the method channel is +ready. No second package owns window lifecycle. + +The native single-instance IPC accepts only exact runner-generated normal, +`--start-minimized`, `.ics`, `webcal:`, and bounded notification JSON shapes. It +rejects unsupported kinds, actions, schemes, files, fields, malformed +UTF-8/JSON, NULs, and oversized data; Dart independently validates the common +activation model again. + +Toast callbacks normally arrive through the notification plugin's registered +COM activator in the primary process. If Windows starts an activation-server +process while the primary process already owns the mutex, the runner starts a +private, hidden forwarding mode instead of the application composition. That +mode initializes only the notification callback, starts no providers or Drift +database, forwards the validated stable-ID activation through the same +current-user pipe, and exits. This preserves notification actions without ever +letting a second process open BusyMax data. + +## Lifecycle and tray + +The Windows tray adapter uses `tray_manager`; Linux retains its DBus/XDG +adapter. Shared presentation state supplies Open BusyMax, new event, new task, +today/agenda, synchronize, Settings, and Quit. Close hides only when the setting +is enabled and the tray is available. A failed tray causes the sole window to +remain visible. The icon and menu are periodically reapplied so Explorer's +notification-area restart can recover. + +Explicit Quit stops reminder and synchronization schedulers, destroys the tray, +disposes the Windows notification backend, closes Drift, and finally requests +native termination. Closing without background mode exits through the normal +runner lifecycle. + +## Activation routes + +Manifest `.ics` and `webcal` activations enter the same single-instance bridge. +An `.ics` file always opens the existing review and confirmation flow; it is +never imported silently. A `webcal:` URI opens the existing subscription +confirmation. Cold and warm delivery use the same common activation model. + +Windows toast activation uses `flutter_local_notifications` with stable AUMID +and committed CLSID `7B854A6D-8B2A-45A5-B998-1F51EC5A81D7`. Toast arguments +contain only stable internal IDs. Body, Open, Snooze, and Dismiss callbacks are +routed to the idempotent scheduler action handler; event and task text is not +placed in activation arguments. + +## Startup and timezone + +The MSIX declares one disabled-by-default `BusyMaxStartupTask`. The runner uses +the packaged StartupTask API and preserves enabled, disabled, user-disabled, +policy-disabled, and unpackaged states. There is no registry or Startup-folder +fallback. + +`flutter_timezone` supplies the Windows system IANA zone. Recognized IANA values +are used directly; a deterministic Windows-ID mapping covers defensive fallback +cases. An unrecognized or unavailable result records a non-sensitive diagnostic +and falls back to `Etc/UTC`, never an assumed local zone. Timezone search uses +BusyMax's generic IANA catalog, not GWeather. diff --git a/docs/windows_development.md b/docs/windows_development.md new file mode 100644 index 00000000..e2e1e3ea --- /dev/null +++ b/docs/windows_development.md @@ -0,0 +1,118 @@ +# Windows development + +BusyMax supports Windows 11 24H2 (`10.0.26100.0`) or newer on x64. Windows 10, +x86, ARM64, MSI, and EXE installers are intentionally outside the target. +Flutter and Dart must come from the repository's pinned Flutter 3.44.4 SDK. + +## Prerequisites + +- A 64-bit Windows build host for compilation and unsigned packaging. The + GitHub workflow uses `windows-latest`. +- 64-bit Windows 11 24H2 or newer for installing, exercising, signing, and + WACK-validating the release-equivalent package. +- Flutter 3.44.4 with Windows desktop enabled. +- Visual Studio 2022 with **Desktop development with C++**, including the x64 + MSVC toolchain and CMake tools. +- Windows SDK `10.0.26100.0` or newer. +- Pester 5 (CI pins 5.7.1) for packaging and WACK parser contracts. +- Developer Mode for unpackaged development, or a local test certificate for + installed MSIX testing. + +Check the build toolchain from a PowerShell prompt at the repository root. +This requires SDK 10.0.26100.0 or newer and never lowers the MSIX manifest's +Windows 11 24H2 minimum: + +```powershell +flutter config --enable-windows-desktop +.\tool\windows\check_prerequisites.ps1 +``` + +## Entry points + +Entrypoints are always explicit. The Windows application must never be built +from the Linux default entrypoint. + +```powershell +flutter pub get +flutter gen-l10n +dart run build_runner build --delete-conflicting-outputs --force-jit +flutter run -d windows -t lib/main_windows.dart ` + --dart-define=BUSYMAX_WINDOWS_AUMID=BusyStack.BusyMax.Development ` + --dart-define=GOOGLE_OAUTH_CLIENT_ID= ` + --dart-define=MICROSOFT_OAUTH_CLIENT_ID= +``` + +An unpackaged build accurately reports Windows StartupTask as unavailable. It +does not create a registry or Startup-folder fallback. Windows notifications +are also deliberately unavailable without installed package identity, and the +Settings page shows that limitation. Display, cancellation, and action +callbacks must be exercised from a locally installed test-signed MSIX. + +Linux continues to use: + +```bash +flutter run -d linux -t lib/main_linux.dart +flutter build linux --release -t lib/main_linux.dart +``` + +## Source validation + +Run these before requesting Windows CI: + +```powershell +flutter gen-l10n +dart run build_runner build --delete-conflicting-outputs --force-jit +dart format --set-exit-if-changed . +flutter analyze +flutter test +dart run tool/check_platform_boundaries.dart +``` + +The Windows workflow can be started manually with `workflow_dispatch` and runs +automatically for pushes to `main` and `Release/**`. This permits validation of +the exact release-branch commit without first merging it into `main`. + +The boundary checker fails if Windows/common code reaches Yaru, Ubuntu +localizations, DBus, freedesktop notifications, XDG tray, or GTK services, or +if business code reaches Fluent UI. + +## Baseline recorded before the port + +The original Linux tree was checked with Flutter 3.44.4 using `flutter pub +get`, `flutter gen-l10n`, `dart run build_runner build +--delete-conflicting-outputs --force-jit`, the formatting check, `flutter +analyze`, `flutter test`, and `flutter build linux --release`. Dependency +resolution, generation, formatting, analysis, and the Linux release build +succeeded. The test run recorded 1,508 passing tests, 10 skipped tests, and five +pre-existing failures: one Nextcloud recurrence parse/serialize case and four +account-add routing widget cases. This is a baseline record, not a waiver for +CI; the release workflows require a fully passing current test suite. + +The Drift schema version was 13 and remains 13. Existing migrations, the Linux +Snap configuration, and the Linux workflow remain in place. + +## Current source-side validation + +On 2026-08-31, the post-port working tree was validated on Linux with Flutter +3.44.4 and Dart 3.12.2: + +| Command | Result | +| --- | --- | +| `flutter gen-l10n` | Passed; every supported catalog generated. | +| `dart run build_runner build --delete-conflicting-outputs` | Passed; generated Drift content remained consistent and the schema version remained 13. The pinned build runner reported that the legacy delete-conflicting option is ignored. | +| `dart format --output=none --set-exit-if-changed .` | Passed; 459 files checked, zero changes required. | +| `flutter analyze` | Passed; no issues found. | +| `dart run tool/check_platform_boundaries.dart` | Passed. | +| `flutter test --reporter compact` | Passed; 1,568 tests passed, 10 skipped, zero failed. | +| `flutter build linux --release -t lib/main_linux.dart` | Passed; produced `build/linux/x64/release/bundle/busymax`. | + +These results establish Linux and platform-neutral source health only. They do +not replace the Windows gates below, a Windows CI result, or installed-package +testing. + +## Windows-only verification + +Compilation, native bridge validation, installed-package activation, package +identity notifications, StartupTask, screenshots, and WACK cannot be certified +from Linux. Run the checklist in [windows_release_checklist.md](windows_release_checklist.md) +on a clean Windows 11 host and retain the resulting artifacts. diff --git a/docs/windows_packaging.md b/docs/windows_packaging.md new file mode 100644 index 00000000..d1377258 --- /dev/null +++ b/docs/windows_packaging.md @@ -0,0 +1,171 @@ +# Windows MSIX packaging + +BusyMax produces an x64 Microsoft Store-mode MSIX. The scripts never upload or +deploy it. The production artifact is unsigned locally because Microsoft Store +signing is separate from local test signing. + +## Identity configuration + +Copy `config/windows_store.example.json` to the ignored path +`config/windows_store.local.json` and replace every placeholder with values the +owner obtained for this product: + +- package identity name; +- Publisher CN; +- publisher display name; +- four-part Store package version; +- previous package version when monotonic comparison is wanted; +- privacy-policy, support, and homepage HTTPS URLs; +- Google desktop OAuth client ID/configuration; +- Microsoft public client ID and authority tenant; + +Do not commit the local file. Identity values are not secrets, but they must +match the final product registration exactly. OAuth tokens, authorization +codes, certificates, PFX files, and passwords must never enter configuration or +the package. + +The product version remains in `pubspec.yaml`. `msixVersion` is independent and +must have four numeric components, a first component of at least 1, a fourth +component of 0, and components no greater than 65535. If a previous version is +provided, the new value must be greater. CI deliberately uses non-production +`1.0.0.0`; it is not an owner production version. + +The scripts enforce three non-interchangeable validation modes: + +- `ProductionStore` is selected by the normal release build. It requires the + owner's final identity and `production=true`. +- `CiNonProduction` is selected only by `-Ci`. It requires + `production=false` and the exact committed CI identity used by the Windows + workflow. An owner or production configuration is rejected. +- `LocalTestSigning` is selected by the certificate and installation scripts. + It accepts the owner's complete production identity so the local certificate + subject can exactly match the Publisher rendered into the package. It never + applies CI identity rules. + +Every mode rejects placeholders, fake data, development backends, invalid +Store versions, and incomplete inputs. Production and local-test-signing modes +also require production HTTPS and OAuth configuration. + +## Build + +From a 64-bit Windows 11 PowerShell prompt: + +```powershell +.\tool\windows\build_release.ps1 ` + -ConfigPath config\windows_store.local.json +``` + +The script checks Windows, Flutter 3.44.4, Visual Studio x64 C++ tools, and a +Windows 11 SDK; resolves dependencies; generates localization and Drift code; +checks committed generation, formatting, analysis, tests, architecture +boundaries, PowerShell contracts, native runner/plugin tests, and release +inputs; builds `lib/main_windows.dart`; stages release runtime files; renders +and validates the manifest; packs the MSIX; and prints its path, metadata, and +SHA-256. It performs no network deployment. + +`MaxVersionTested` is taken from the Windows SDK selected by the prerequisite +check, so the manifest cannot claim a newer SDK than the release builder used. + +The manifest is rendered reproducibly from +`tool/windows/AppxManifest.xml.template`. It declares x64, minimum OS +`10.0.26100.0`, only internet client and full-trust desktop capabilities, `.ics`, +`webcal`, StartupTask, toast activation, resource languages derived from ARB +catalogs, and BusyMax visual assets. It contains no location, microphone, +webcam, contacts, broad-filesystem, or private-network capability. + +Toast activation uses the base `desktop:Extension` and +`desktop:ToastNotificationActivation` schema. Its CLSID is validated against +both the COM class and the notification backend constant. The obsolete +`desktop4` namespace is neither declared nor accepted for this extension. + +`msix` remains pinned as the repository's Store packaging tool dependency, but +its generated configuration does not express BusyMax's complete combination of +StartupTask parameters, full-trust toast COM activation, and resource-derived +languages. The release script therefore follows the documented deterministic +fallback: stage the Flutter release, render the committed manifest template, +validate it, and invoke the selected Windows SDK's `MakeAppx`. No generated +output is hand-edited. + +Runner, tray, file-association, and package logo variants are derived from the +existing `assets/branding/busymax-logo.png`; no separate product mark is used. +After changing that source branding asset, regenerate and review all committed +Windows assets with: + +```powershell +dart run tool/generate_windows_assets.dart +``` + +The Store package deliberately stages concrete, unqualified PNG files for all +four manifest logo paths. Scale-qualified source variants remain committed for +review and possible future PRI-based packaging, but this packaging path does +not place them in the MSIX. `validate_package_contents.ps1` rejects a staged +`.scale-*` or `.targetsize-*` asset and confirms every logo reference resolves +to a real file after the exact MSIX is unpacked. Consequently this path does +not require or pretend to contain `resources.pri`. + +The startup extension passes `--start-minimized` through its schema-valid +`uap10:Parameters` attribute. The nested `desktop:StartupTask` remains disabled +by default; that element does not define an arguments attribute. + +## Local test signing and installation + +For installed-package testing only: + +```powershell +.\tool\windows\create_test_certificate.ps1 ` + -ConfigPath config\windows_store.local.json +.\tool\windows\install_test_package.ps1 ` + -ConfigPath config\windows_store.local.json ` + -PackagePath build\windows\store\BusyMax--x64.msix ` + -PfxPath build\windows\test-signing\busymax-test-only.pfx +``` + +The password is prompted, never stored. PFX/CER files and the signed copy stay +under ignored build output. The installer prints a package-specific uninstall +command for cleanup. Both scripts validate in `LocalTestSigning` mode. They +verify the generated/imported certificate subject against the configured +Publisher, and the installer unpacks the actual MSIX and verifies the same +subject against its `AppxManifest.xml` before signing. The unsigned Store-mode +artifact must not depend on this development certificate. + +## Validation + +The staging step includes only the whitelisted, version-matched x64 Visual C++ +redistributable DLLs from the selected Visual Studio installation, so a clean +machine does not need developer tools or a preinstalled VC runtime. + +`validate_manifest.ps1` enforces architecture, OS target, extensions, +stable application/executable identity, StartupTask defaults, the exact `.ics` +and `webcal` registration set, capabilities, and placeholder removal. +`validate_package_contents.ps1` rejects source, PFX/CER, logs, databases, test +data, fake data, debug symbols, Linux/Snap content, extra executables, and +template branding while checking required Flutter assets and DLLs. + +Validation is not limited to staging. After `MakeAppx` packs the artifact, the +packaging script unpacks that exact MSIX into a new clean directory, reruns the +manifest/content checks, writes a per-file SHA-256 inventory, and compares the +unpacked inventory with the deterministic staging inventory (excluding only +the package metadata files produced by `MakeAppx`). Both inventories are CI +artifacts. `MakeAppx pack /v` is also the mandatory Windows SDK schema and +package validation gate; a successful custom XML check alone cannot produce an +artifact. + +After installing a release-equivalent locally signed package, run: + +```powershell +.\tool\windows\run_wack.ps1 ` + -PackagePath build\windows\store\BusyMax-test-signed.msix +``` + +Retain the XML report. Any native runner, dependency, manifest, or packaging +change invalidates an earlier WACK result and requires a repeat. See the +[release checklist](windows_release_checklist.md) for behavioral coverage. + +`run_wack.ps1` parses both `OVERALL_RESULT` and every per-test `RESULT`; a zero +process exit code or a report file by itself is not accepted. Missing, empty, +malformed, result-free, or failing reports stop the release. When WACK emits a +warning, create a local JSON object whose keys exactly match the warning names +printed by the script and whose values are the written release dispositions, +then pass it with `-WarningDispositionPath`. The script refuses warnings with +missing, placeholder, or empty dispositions and emits `wack-summary.json` +beside the complete XML report. diff --git a/docs/windows_release_checklist.md b/docs/windows_release_checklist.md new file mode 100644 index 00000000..b9710c2d --- /dev/null +++ b/docs/windows_release_checklist.md @@ -0,0 +1,103 @@ +# Windows release checklist + +This checklist is evidence-driven. Source review or a Linux build cannot mark +the Windows package ready. Record host OS/build, display setup, package version, +MSIX filename/SHA-256, commands, results, screenshots, and WACK report beside +the release candidate. + +## Automated gates + +- [ ] Clean checkout uses Flutter 3.44.4. +- [ ] Localization and build-runner generation are committed and reproducible. +- [ ] Formatting, analyzer, unit tests, widget tests, and platform-boundary + checks pass on Linux and `windows-latest`. +- [ ] Linux release builds explicitly from `lib/main_linux.dart`; Snap checks + remain green and Yaru/GTK behavior is unchanged. +- [ ] Windows x64 release builds explicitly from `lib/main_windows.dart`. +- [ ] CI creates only the explicitly non-production unsigned test MSIX and + uploads it and test reports as CI artifacts; no deployment job exists. +- [ ] Manifest and package-content validators pass. +- [ ] Executable runner, timezone, and tray native tests pass on Windows. +- [ ] The packed MSIX is unpacked to a clean directory; the exact final + manifest, runtime inventory, and per-file SHA-256 report pass validation. + +## Installed package lifecycle + +- [ ] Ordinary start, offline start, and `--start-minimized`. +- [ ] Close-to-tray enabled/disabled, explicit Quit, reopen and restore. +- [ ] Second launch cannot open Drift and restores the primary instance. +- [ ] Explorer restart recovers the tray; tray failure leaves a visible window. +- [ ] Sign-out/restart and launch-at-login behavior. +- [ ] StartupTask disabled by default, user enable/disable, start minimized, + user-disabled, policy-disabled, and unpackaged states. + +## Activation and notifications + +- [ ] Cold, warm, hidden-window, and initialization-time `.ics` activation opens + review/confirmation without silent import. +- [ ] Cold/warm/hidden `webcal:` enters subscription confirmation. +- [ ] Malformed URI/JSON/UTF-8, unsupported file/scheme, oversized input, and + concurrent activations are safely rejected or serialized. +- [ ] Notification display, body click, Open, Snooze, Dismiss, duplicate action, + cancellation, warm/cold activation, hidden window, and package-identity + behavior pass. +- [ ] Activation arguments contain stable internal IDs only and no title, body, + account email, token, code, or secret. + +## Accounts, storage, and files + +- [ ] Google and Microsoft browser/loopback OAuth pass unpackaged, installed + test-signed, and final Store-mode configurations, including cancellation, + timeout, PKCE/state validation, and restored focus. +- [ ] Secure credentials persist after restart and are removed on sign-out and + account removal; no plaintext preference/log/payload contains a token. +- [ ] Existing Drift schema/migrations, SQLite DLLs, restart, package update, + backup/recovery, Unicode paths, and long paths pass. +- [ ] `.ics` import/export, overwrite, cancellation, read-only destination, + Unicode/long filename, malformed iCalendar, and WebCal pass without broad + filesystem capability. +- [ ] Recurrence and timezone behavior covers UTC, standard/DST, skipped and + ambiguous local time, non-hour offsets, and a system-zone change followed + by restart. + +## UI and accessibility + +- [ ] Every existing screen has Fluent presentation; no Windows-visible Yaru, + Ubuntu, Material shell control, or Flutter template icon remains. +- [ ] Native title bar, caption controls, snap/maximize/restore/system menu. +- [ ] Light, dark, high contrast, reduced motion, system accent changes, text + scaling, keyboard-only order/focus/tooltips, and basic Narrator. +- [ ] 100%, 125%, 150%, and 200%; 1280x800 and 1920x1080; mixed-DPI monitors. +- [ ] Arabic and Persian RTL through navigation, dialogs, and editors; all + other supported locales open without English Windows placeholders. +- [ ] Real installed-MSIX light/dark screenshots captured at both target sizes, + including at least one 125% or 150% run. +- [ ] Windows 11 24H2 and 25H2 both exercised. + +## Final package and WACK + +- [ ] Production configuration has exact owner identity/OAuth values, valid + HTTPS privacy/support URLs, fake data off, and no development backend. +- [ ] Product version and Store package version are displayed distinctly. +- [ ] MSIX is x64, minimum `10.0.26100.0`, fourth version segment 0, expected + capabilities/extensions/languages/assets only, and newer than the prior + supplied version. +- [ ] Manual content review finds no certificate/PFX, secret/token, logs, + database, source, debug symbols, test data, Linux content, unreferenced + assets, or missing Flutter/plugin/SQLite runtime DLL. +- [ ] Clean Windows test environment without build tools installs and runs. +- [ ] WACK finishes with zero failures; every warning is investigated and its + written disposition, complete XML report, and parsed summary are + retained. Repeat after package-affecting changes. + +## Release record + +- [ ] Architecture/dependency summary. +- [ ] Exact commands and results for Linux, Windows, CI, installed MSIX, and + WACK. +- [ ] CI run links/artifact identifiers. +- [ ] Windows screenshots. +- [ ] MSIX filename, version, architecture, minimum OS, and SHA-256. +- [ ] WACK report path/result. +- [ ] Exact remaining external owner values, if any. +- [ ] Confirmation that no package was uploaded or deployed. diff --git a/docs/windows_ui.md b/docs/windows_ui.md new file mode 100644 index 00000000..996be80f --- /dev/null +++ b/docs/windows_ui.md @@ -0,0 +1,68 @@ +# Windows UI + +Windows uses `FluentApp.router` and Fluent controls throughout the application +shell. It is a separate presentation target, not the Linux Yaru widget tree in +a Windows runner. + +## Shell and destinations + +`NavigationView` maps only the existing Schedule, Tasks, and Settings areas. +Settings is a footer destination. The source/calendar/task-list sidebar remains +a content-specific pane and collapses at BusyMax-owned breakpoints so narrow +windows do not show two permanently expanded sidebars. + +The Windows presentation covers sign-in and account addition; schedule day, +week, month, year, and agenda views; source visibility; task lists/details; +calendar and task-list creation/rename/removal and reminder/color actions; +event/task editors; recurrence; reminders; date/time and timezone inputs; +calendar import and WebCal subscription; settings; account removal; +diagnostics; feedback; About; third-party licenses; and keyboard shortcuts. The +view/controller and provider logic remains shared. + +Existing-task editing is capability-aware. Microsoft and DAV start, due, +reminder, recurrence, category/importance and iCalendar fields are patched +through `TaskDetailsDraft`; unchanged all-day values, unsupported recurrence, +and multi-alarm data are preserved instead of being normalized by merely +opening and saving the dialog. The Fluent task dialog also exposes supported +cross-list moves, hierarchy and checklist mutations, reparenting, DAV +duplication, and native iCalendar export. Event reminders retain multiple +Google/DAV values while Microsoft remains limited to its single provider +reminder. New tasks expose explicit all-day/timed scheduling and retain every +DAV alarm. Event and task editors confirm before discarding unsaved edits, +including Escape/back and hierarchy navigation. + +BusyMax presentation contracts use semantic glyphs such as calendar, task, +add, delete, synchronize, reminder, and diagnostics. The Windows mapper uses +Microsoft Fluent System Icons. Feature code does not request `YaruIcons` or +Material `Icons` for Windows chrome. + +## Theme and accessibility + +- System, light, and dark modes follow the persisted BusyMax preference. +- Accent color follows the Windows system accent. +- High contrast uses opaque black/white Fluent surfaces. +- Reduced-motion preference zeroes Fluent animation durations. +- Segoe UI Variable is requested and Windows supplies its own fallback; no + Microsoft font is bundled. +- Native text scaling, keyboard traversal, focus indicators, tooltips, + screen-reader semantics, and RTL direction flow through Flutter/Fluent. +- The supported Windows validation matrix is 100%, 125%, 150%, and 200% display + scaling, mixed-DPI monitors, 1280x800 and 1920x1080, and Arabic/Persian RTL. + +The native title bar remains untouched. There are no Flutter-drawn minimize, +maximize, restore, or close controls and Mica is not a release dependency. + +## Compatibility subtrees + +Material controls are not used as Windows application chrome. If a custom or +third-party calendar subtree requires Material internals, compatibility must be +narrowly scoped to that subtree and inherit the Fluent brightness, color, +typography, locale, and direction. Material surfaces must not escape into the +shell. Ubuntu localization delegates remain Linux-only. + +## Visual verification + +Widget tests are not substitutes for native rendering. The release checklist +requires real installed-MSIX screenshots of major screens in light and dark at +1280x800 and 1920x1080, including a 125% or 150% run. Capture RTL, high-contrast, +focus, and mixed-DPI observations in the same Windows test record. diff --git a/l10n.yaml b/l10n.yaml index 80ed2b76..c160ef61 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -3,6 +3,10 @@ template-arb-file: app_en.arb output-dir: lib/l10n/generated output-localization-file: app_localizations.dart nullable-getter: false +# Flutter requires a base `pt` fallback before it will generate `pt_PT`. +# The base stub is not exposed by BusyMax; target-catalog parity is enforced by +# test/app/localization_audit_test.dart. +untranslated-messages-file: .dart_tool/pt_fallback_untranslated.json header: | // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 3556542b..27cfa7f7 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1,14 +1,31 @@ { "@@locale": "ar", + "windowsSupport": "الدعم", + "windowsThirdPartyLicenses": "تراخيص الجهات الخارجية", + "windowsSearch": "بحث", + "windowsStartupDisabledByUser": "عطّل المستخدم هذه الميزة في إعدادات Windows.", + "windowsStartupDisabledByPolicy": "معطّلة بواسطة نهج Windows.", + "windowsStartupUnavailable": "تتوفر بعد تثبيت BusyMax من حزمة MSIX.", + "windowsReminderExitNotice": "تتوقف التذكيرات عند إنهاء BusyMax بالكامل. أبقِه قيد التشغيل في الخلفية لتلقيها.", + "windowsProductVersionLabel": "إصدار المنتج", + "windowsPackageVersionLabel": "إصدار حزمة Windows", + "windowsUnpackaged": "غير معبأ", + "windowsAgendaLoadMore": "تحميل المزيد من عناصر جدول الأعمال", + "repeatWeeklyDaySummary": "{dayKey, select, MO{الاثنين} TU{الثلاثاء} WE{الأربعاء} TH{الخميس} FR{الجمعة} SA{السبت} SU{الأحد} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "في يومي {first} و{second} من الشهر", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} في يومي {firstDay} و{secondDay} من {month}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} في اليوم {day} من شهري {firstMonth} و{secondMonth}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} في يومي {firstDay} و{secondDay} من شهري {firstMonth} و{secondMonth}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} في الأيام {days} من شهري {firstMonth} و{secondMonth}", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "اربط حسابات Google أو Microsoft أو تقويم Apple iCloud أو Nextcloud.", "googlePermissionsConsentNotice": "في شاشة أذونات Google، حدّد أذونات التقويم والمهام معًا.", "googlePermissionsRequiredRetry": "أذونات تقويم Google وGoogle Tasks مطلوبة. حاول مرة أخرى وحدّد مربعي الاختيار.", "finishSetup": "إنهاء الإعداد", "continueSetup": "متابعة", "onboardingSetupTitle": "إعداد BusyMax", "onboardingAccountsStepTitle": "ربط الحسابات", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "أضف كل الحسابات التي تريد استخدامها. يزامن BusyMax التقويمات والأحداث وقوائم المهام والمهام المدعومة من كل حساب.", "onboardingPreferencesStepTitle": "اختيار إعدادات النظام", "onboardingPreferencesStepDescription": "اضبط سلوك التطبيق على سطح المكتب والتذكيرات ومستوى تفاصيل الإشعارات والمظهر قبل فتح جدولك.", "signInWithGoogle": "تسجيل الدخول باستخدام Google", @@ -33,6 +50,43 @@ "calendarTasks": "المهام", "calendar": "التقويم", "calendars": "التقويمات", + "newCalendar": "تقويم جديد", + "calendarColor": "لون التقويم", + "calendarColorOption": "اللون {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "لا يدعم هذا المزوّد إدارة التقويمات في BusyMax.", + "primaryCalendarCannotDelete": "لا يمكن حذف التقويم الأساسي.", + "calendarCreateFailed": "تعذّر إنشاء التقويم: ⁨{error}⁩", + "calendarCreatedRefreshPending": "تم إنشاء التقويم، لكن تعذّر على BusyMax تحديث الحساب. سيظهر بعد المزامنة التالية.", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "تعذّر تحديث التقويم: ⁨{error}⁩", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "تعذّر حذف التقويم: ⁨{error}⁩", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "حدث جديد", "refreshCalendar": "تحديث التقويم", "openInProvider": "فتح في الخدمة", @@ -40,7 +94,7 @@ "showInSchedule": "إظهار في الجدول", "noCalendarsSynced": "لم تتم مزامنة أي تقويمات بعد.", "allDay": "طوال اليوم", - "moreItems": "+⁨{count}⁩ عناصر أخرى", + "moreItems": "{count, plural, one{+عنصر واحد آخر} two{+عنصران آخران} few{+⁨{count}⁩ عناصر أخرى} many{+⁨{count}⁩ عنصرًا آخر} other{+⁨{count}⁩ عنصر آخر}}", "noEventsOrTasks": "لا توجد أحداث أو مهام", "scheduleLoading": "جارٍ تحميل الجدول...", "scheduleUnavailable": "الجدول غير متاح", @@ -52,6 +106,55 @@ "scheduleNoSearchResultsDescription": "جرّب بحثًا مختلفًا أو امسح عوامل التصفية الحالية.", "refresh": "تحديث", "trayOpenBusyMax": "فتح BusyMax", + "trayShowBusyMax": "إظهار BusyMax", + "trayNewEvent": "حدث جديد…", + "trayNewTask": "مهمة جديدة…", + "trayToday": "اليوم", + "trayAllDay": "طوال اليوم", + "trayNow": "الآن", + "trayCalendarEvent": "حدث في التقويم", + "trayUntitledEvent": "حدث بلا عنوان", + "trayNothingElseToday": "لا شيء آخر اليوم", + "trayTasksDueToday": "{count, plural, =0{لا توجد مهام مستحقة اليوم} =1{مهمة واحدة مستحقة اليوم} =2{مهمتان مستحقتان اليوم} few{⁨{count}⁩ مهام مستحقة اليوم} many{⁨{count}⁩ مهمة مستحقة اليوم} other{⁨{count}⁩ مهمة مستحقة اليوم}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "فتح جدول أعمال اليوم", + "traySyncNow": "مزامنة الآن", + "traySyncing": "جارٍ العمل على المزامنة…", + "trayNotConnected": "غير متصل", + "trayNotYetSynced": "لم تتم المزامنة بعد", + "trayLastSyncedJustNow": "تمت المزامنة للتو", + "trayLastSyncedMinutesAgo": "{count, plural, one{تمت المزامنة قبل دقيقة واحدة} two{تمت المزامنة قبل دقيقتين} few{تمت المزامنة قبل ⁨{count}⁩ دقائق} many{تمت المزامنة قبل ⁨{count}⁩ دقيقة} other{تمت المزامنة قبل ⁨{count}⁩ دقيقة}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, one{تمت المزامنة قبل ساعة واحدة} two{تمت المزامنة قبل ساعتين} few{تمت المزامنة قبل ⁨{count}⁩ ساعات} many{تمت المزامنة قبل ⁨{count}⁩ ساعة} other{تمت المزامنة قبل ⁨{count}⁩ ساعة}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, one{تمت المزامنة قبل يوم واحد} two{تمت المزامنة قبل يومين} few{تمت المزامنة قبل ⁨{count}⁩ أيام} many{تمت المزامنة قبل ⁨{count}⁩ يومًا} other{تمت المزامنة قبل ⁨{count}⁩ يوم}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "الإعدادات", + "trayQuitBusyMax": "إنهاء BusyMax", "agendaLoadMoreOverdue": "تحميل المزيد من المهام المتأخرة", "agendaLoadMoreNoDate": "تحميل المزيد من المهام بلا تاريخ", "viewDay": "يوم", @@ -109,7 +212,7 @@ "sensitivityPersonal": "شخصي", "tasks": "المهام", "allTasks": "كل المهام", - "tasksInList": "المهام في ⁨{title}⁩", + "tasksInList": "المهام في ⁨⁨{title}⁩⁩", "taskLists": "قوائم المهام", "navigation": "التنقل", "mainMenu": "القائمة الرئيسية", @@ -154,7 +257,7 @@ "feedbackDetailedMessage": "رسالة مفصّلة", "feedbackReplyEmail": "البريد الإلكتروني للرد (اختياري)", "feedbackIncludeTechnicalDetails": "تضمين التفاصيل التقنية", - "feedbackTechnicalDetailsDisclosure": "يضيف فقط إصدار نظام التشغيل Linux ولغة التطبيق ومنطقته. لا يتم تضمين أي سجلات أو بيانات حسابات أو أسماء ملفات أو معلومات تشخيصية أخرى.", + "feedbackTechnicalDetailsDisclosure": "يضيف فقط اسم نظام التشغيل وإصداره ولغة التطبيق ومنطقته. لا يتم تضمين أي سجلات أو بيانات حسابات أو أسماء ملفات أو معلومات تشخيصية أخرى.", "feedbackCategoryRequired": "اختر فئة.", "feedbackSubjectLengthError": "يجب أن يتراوح الموضوع بين 3 و120 حرفًا.", "feedbackMessageLengthError": "يجب أن تتراوح الرسالة بين 10 و5,000 حرف.", @@ -164,7 +267,7 @@ "feedbackRateLimitedError": "أُرسلت ملاحظات كثيرة جدًا من هذه الشبكة. انتظر وحاول مرة أخرى.", "feedbackRejectedError": "رفض الخادم الإرسال. راجع الحقول وحاول مرة أخرى.", "feedbackServerError": "يتعذر على BusyStack قبول ملاحظاتك الآن. لم تُمسح ملاحظاتك؛ حاول مرة أخرى.", - "feedbackSuccess": "تم إرسال الملاحظات. المرجع: ⁨{id}⁩", + "feedbackSuccess": "تم إرسال الملاحظات. المرجع: ⁨⁨{id}⁩⁩", "toggleSidebar": "إظهار الشريط الجانبي أو إخفاؤه", "showSidebar": "إظهار اللوحة الجانبية", "hideSidebar": "إخفاء اللوحة الجانبية", @@ -179,8 +282,8 @@ "removeAccount": "إزالة الحساب…", "removingAccount": "جارٍ إزالة الحساب…", "removeAccountDescription": "إيقاف المزامنة وإزالة بيانات هذا الحساب من هذا الجهاز.", - "removeAccountTitle": "إزالة ⁨{account}⁩ من BusyMax؟", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountTitle": "إزالة ⁨⁨{account}⁩⁩ من BusyMax؟", + "removeAccountConfirmation": "سيؤدي هذا إلى حذف المهام والتقويمات والأحداث والتذكيرات والتغييرات غير المتصلة المخزنة مؤقتًا من هذا الجهاز. ستُفقد التغييرات التي لم تتم مزامنتها، ولن تُحذف نسخ التقويمات والأحداث وقوائم المهام والمهام لدى موفّر الخدمة.", "revokeGoogleAccess": "إلغاء وصول BusyMax إلى حساب Google هذا أيضًا", "revokeGoogleAccessDescription": "ستحتاج إلى منح الوصول مرة أخرى قبل إعادة الاتصال.", "removeAccountAction": "إزالة الحساب", @@ -196,7 +299,7 @@ "deleteList": "حذف القائمة", "builtInMicrosoftList": "مدمجة", "builtInMicrosoftListCannotRenameDelete": "لا يمكن إعادة تسمية قوائم Microsoft To Do المدمجة أو حذفها.", - "deleteListConfirmation": "حذف «⁨{title}⁩» من Google Tasks؟", + "deleteListConfirmation": "حذف \"⁨⁨{title}⁩⁩\" من Google Tasks؟", "deleteEvent": "حذف الحدث", "title": "العنوان", "create": "إنشاء", @@ -206,9 +309,9 @@ "refreshAll": "تحديث الكل", "listRefreshed": "تم تحديث القائمة.", "allTasksRefreshed": "تم تحديث جميع الحسابات.", - "exportedFile": "تم التصدير إلى ⁨{path}⁩", - "exportFailed": "فشل التصدير: ⁨{error}⁩", - "refreshFailed": "فشل التحديث: ⁨{error}⁩", + "exportedFile": "تم التصدير إلى ⁨⁨{path}⁩⁩", + "exportFailed": "فشل التصدير: ⁨⁨{error}⁩⁩", + "refreshFailed": "فشل التحديث: ⁨⁨{error}⁩⁩", "selectOrCreateTaskList": "اختر قائمة مهام أو أنشئ واحدة للبدء.", "signInToViewTasks": "سجّل الدخول لعرض المهام.", "noTasks": "لا توجد مهام.", @@ -221,8 +324,8 @@ "upcoming": "القادمة", "noDate": "بلا تاريخ", "completed": "مكتملة", - "duePrefix": "مستحقة في ⁨{date}⁩", - "dateTimeDisplay": "⁨{date}⁩ · ⁨{time}⁩", + "duePrefix": "مستحقة في ⁨⁨{date}⁩⁩", + "dateTimeDisplay": "⁨⁨{date}⁩⁩ · ⁨⁨{time}⁩⁩", "taskDetails": "تفاصيل المهمة", "editTask": "تعديل المهمة", "noTaskSelected": "لم يتم تحديد مهمة.", @@ -233,7 +336,7 @@ "primarySection": "أساسي", "statusSection": "الحالة", "openStatus": "مفتوحة", - "doneStatus": "منجزة", + "doneStatus": "منجز", "notes": "ملاحظات", "dueDate": "تاريخ الاستحقاق", "clearDueDate": "مسح تاريخ الاستحقاق", @@ -277,7 +380,7 @@ "moveToTop": "نقل إلى الأعلى", "deleteTask": "حذف المهمة", "newSubtask": "مهمة فرعية جديدة", - "deleteTaskConfirmation": "حذف «⁨{title}⁩»؟", + "deleteTaskConfirmation": "حذف «⁨⁨{title}⁩⁩»؟", "metadata": "البيانات الوصفية", "id": "المعرّف", "etag": "ETag", @@ -291,7 +394,8 @@ "synced": "تمت المزامنة", "account": "الحساب", "sync": "المزامنة", - "manualFullSync": "مزامنة كاملة يدويًا", + "forceFullResync": "فرض إعادة مزامنة كاملة", + "forceFullResyncDescription": "إعادة تحميل جميع البيانات بالكامل من كل حساب متصل. استخدم هذا الخيار فقط لاستكشاف مشكلات المزامنة وإصلاحها.", "runInBackgroundWhenClosed": "متابعة التشغيل عند إغلاق النافذة", "showTrayIcon": "إظهار أيقونة شريط النظام", "startMinimizedToTray": "البدء مصغّرًا في شريط النظام", @@ -300,11 +404,12 @@ "launchAtLoginFailed": "تعذر تحديث إعداد التشغيل عند تسجيل الدخول.", "requiresTrayIcon": "يتطلب أيقونة شريط النظام.", "syncComplete": "اكتملت المزامنة.", - "syncFailed": "فشلت المزامنة: ⁨{error}⁩", + "syncFailed": "فشلت المزامنة: ⁨⁨{error}⁩⁩", "notifySyncFailures": "إشعارات عند فشل المزامنة", "notifyConflicts": "إشعارات عند حدوث تعارضات", "notifyDueToday": "إشعارات المهام المستحقة اليوم", "eventReminders": "تذكيرات الأحداث", + "onState": "تشغيل", "taskReminders": "تذكيرات المهام", "notificationDetailLevel": "مستوى تفاصيل الإشعارات", "notificationDetailPrivate": "خاص", @@ -314,9 +419,13 @@ "quietHoursStart": "بداية ساعات الهدوء", "quietHoursEnd": "نهاية ساعات الهدوء", "notifications": "الإشعارات", + "windowsNotificationsUnavailable": "إشعارات Windows غير متاحة", + "windowsNotificationsUnpackaged": "لا يمكن لتشغيل التطوير غير المحزّم هذا استخدام إشعارات Windows. ثبّت حزمة MSIX الموقّعة للاختبار لاختبار التذكيرات.", + "windowsNotificationsInstalledFailure": "تعذّر على BusyMax تهيئة إشعارات Windows. لن تظهر التذكيرات حتى تُحل مشكلة التثبيت هذه.", "appearance": "المظهر", "theme": "السمة", "themeSystem": "النظام", + "settingsSystem": "النظام", "themeLight": "فاتحة", "themeDark": "داكنة", "themeFamily": "عائلة السمات", @@ -329,7 +438,7 @@ "diagnostics": "التشخيصات", "apiInspectorDisabled": "إظهار فاحص API", "googleTasksApi": "واجهة Google Tasks API", - "discoveryRevision": "مراجعة Discovery: ⁨{revision}⁩", + "discoveryRevision": "مراجعة Discovery: ⁨⁨{revision}⁩⁩", "implementedMethods": "الطرق المنفذة", "supportsTasksScopes": "يدعم نطاقَي tasks وtasks.readonly", "requiresTasksScope": "يتطلب نطاق tasks", @@ -337,8 +446,8 @@ "signInToInspectPendingOperations": "سجّل الدخول لفحص العمليات المعلّقة.", "noBlockedPendingOperations": "لا توجد عمليات معلّقة محظورة.", "operationActions": "إجراءات العملية", - "pendingOpListId": "القائمة=⁨{id}⁩", - "pendingOpTaskId": "المهمة=⁨{id}⁩", + "pendingOpListId": "القائمة=⁨⁨{id}⁩⁩", + "pendingOpTaskId": "المهمة=⁨⁨{id}⁩⁩", "pendingOpAttempts": "المحاولات=⁨{count}⁩", "retry": "إعادة المحاولة", "discard": "تجاهل", @@ -350,9 +459,9 @@ "discardPendingOperationConfirmation": "سيؤدي ذلك إلى إزالة العملية المحلية المحظورة. ستُحدّث البيانات من Google Tasks في المزامنة التالية.", "pendingOperationDiscarded": "تم تجاهل العملية المعلّقة.", "syncFailureNotificationTitle": "فشلت مزامنة BusyMax", - "syncFailureNotificationBody": "فشلت المزامنة في الخلفية. ⁨{message}⁩", + "syncFailureNotificationBody": "فشلت المزامنة في الخلفية. ⁨⁨{message}⁩⁩", "conflictNotificationTitle": "تعارض في مزامنة BusyMax", - "conflictNotificationBody": "تم حظر تغيير محلي معلّق. ⁨{summary}⁩", + "conflictNotificationBody": "تم حظر تغيير محلي معلق. ⁨⁨{summary}⁩⁩", "dueTodayNotificationTitle": "المهام المستحقة اليوم", "dueTodayNotificationBody": "{count, plural, =0{لا توجد مهام مستحقة اليوم.} =1{هناك مهمة واحدة مستحقة اليوم.} =2{هناك مهمتان مستحقتان اليوم.} few{هناك ⁨{count}⁩ مهام مستحقة اليوم.} many{هناك ⁨{count}⁩ مهمة مستحقة اليوم.} other{هناك ⁨{count}⁩ مهمة مستحقة اليوم.}}", "eventReminderNotificationTitle": "تذكير بحدث", @@ -361,7 +470,7 @@ "taskReminderNotificationBody": "ستحلّ مهلة المهمة قريبًا.", "notificationOpenAction": "فتح", "notificationSnoozeAction": "غفوة لمدة 10 دقائق", - "notificationDismissAction": "تجاهل", + "notificationDismissAction": "إغلاق", "notificationDetailsHidden": "التفاصيل مخفية وفقًا لإعدادات الخصوصية.", "previousMonth": "الشهر السابق", "nextMonth": "الشهر التالي", @@ -374,52 +483,52 @@ "scheduleItemCount": "{count, plural, =0{لا عناصر} =1{عنصر واحد} =2{عنصران} few{⁨{count}⁩ عناصر} many{⁨{count}⁩ عنصرًا} other{⁨{count}⁩ عنصر}}", "readOnlyCalendar": "هذا التقويم للقراءة فقط.", "selectTimeZone": "اختيار المنطقة الزمنية", - "searchLocations": "البحث عن مواقع", + "searchLocations": "البحث عن المواقع", "noLocationsFound": "لم يتم العثور على مواقع", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "هذا الحقل مطلوب.", + "providerConnectionDescription": "اربط التقويمات والمهام من أحد موفّري الخدمة هؤلاء.", + "appleICloudProvider": "تقويم Apple iCloud", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", - "deleteCalendarConfirmation": "حذف «⁨{title}⁩»؟", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "nextcloudTasksProvider": "مهام Nextcloud", + "addAppleICloudAccount": "إضافة حساب تقويم Apple iCloud", + "addNextcloudAccount": "إضافة حساب Nextcloud", + "waitingForAppleICloud": "جارٍ الاتصال بـ Apple iCloud…", + "waitingForNextcloud": "بانتظار تفويض Nextcloud…", + "connectAppleICloudTitle": "ربط تقويم Apple iCloud", + "appleAccountEmail": "البريد الإلكتروني لحساب Apple", + "appleAppSpecificPassword": "كلمة مرور خاصة بالتطبيق", + "appleAppSpecificPasswordHelp": "أنشئ كلمة مرور خاصة بالتطبيق بعد تفعيل المصادقة الثنائية لحساب Apple.", + "appleAppSpecificPasswordResetWarning": "تؤدي إعادة تعيين كلمة مرور حساب Apple إلى إبطال كلمات المرور الخاصة بالتطبيقات.", + "connectNextcloudTitle": "ربط Nextcloud", + "nextcloudServerUrl": "خادم Nextcloud أو عنوان CalDAV", + "nextcloudServerUrlHelp": "أدخل عنوان URL لخادم Nextcloud أو الصق عنوان CalDAV الأساسي المنسوخ من Nextcloud.", + "nextcloudBrowserAuthorizationHelp": "سيفتح BusyMax متصفحك. وافق على الوصول هناك، ثم عد إلى BusyMax.", + "connectAccountAction": "ربط", + "cancelAccountConnection": "إلغاء الربط", + "nextcloudAccountRemovedRevokeFailed": "تمت إزالة الحساب محليًا، لكن تعذّر إبطال كلمة مرور تطبيق Nextcloud.", + "davCachedOfflineNotice": "تُخزّن بيانات التقويم والمهام محليًا للاستخدام دون اتصال.", + "davReauthenticationRequired": "أعد ربط هذا الحساب لاستئناف المزامنة.", + "davTemporarilyUnavailable": "هذا الحساب غير متاح مؤقتًا.", + "davPermissionChanged": "تغيّرت أذونات الخادم. تم إيقاف التعديلات المعلقة مؤقتًا.", + "davUnsupportedServer": "هذا الخادم أو ملف موفّر الخدمة غير مدعوم.", + "collectionSettings": "التقويمات وقوائم المهام", + "calendarContent": "أحداث التقويم", + "taskContent": "المهام", + "readOnlySharedCollection": "للقراءة فقط", + "pendingLocally": "معلق محليًا", + "conflictBlocked": "محظور بسبب تعارض", + "authenticationBlocked": "محظور حتى إعادة الاتصال", + "operationFailed": "فشلت العملية", + "keepServerVersion": "الاحتفاظ بإصدار الخادم", + "reapplyLocalChange": "مراجعة التغيير المحلي وإعادة تطبيقه", + "duplicateLocalItem": "تكرار كعنصر جديد", + "deleteCalendarConfirmation": "حذف «⁨⁨{title}⁩⁩»؟", + "davConnectionState": "حالة الاتصال", + "davConnected": "متصل", + "davConnecting": "جارٍ الاتصال…", + "davSignedOut": "تم تسجيل الخروج", + "davLastSuccessfulSync": "آخر مزامنة ناجحة: ⁨{time}⁩", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -427,9 +536,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "لم تتم المزامنة بعد", + "refreshCollections": "تحديث التقويمات وقوائم المهام", + "nextcloudServerHost": "الخادم: ⁨{host}⁩", "@nextcloudServerHost": { "placeholders": { "host": { @@ -437,12 +546,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "تقويم أحداث", + "collectionSupportsTasks": "قائمة مهام", + "collectionSupportsEventsAndTasks": "الأحداث والمهام", + "writableCollection": "قابل للكتابة", + "sharedCollection": "مشترك", + "collectionLastSynced": "آخر مزامنة: ⁨{time}⁩", "@collectionLastSynced": { "placeholders": { "time": { @@ -450,7 +559,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "مشكلة في المزامنة: ⁨{code}⁩", "@collectionSyncError": { "placeholders": { "code": { @@ -458,8 +567,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "تعارضات المزامنة", + "remoteChangedAt": "تم التغيير على الخادم في: ⁨⁨{time}⁩⁩", "@remoteChangedAt": { "placeholders": { "time": { @@ -467,7 +576,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "تعديل محلي: ⁨{summary}⁩", "@localPendingEdit": { "placeholders": { "summary": { @@ -475,144 +584,366 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "conflictResolutionFailed": "تعذّر حل التعارض.", + "recurringEventScope": "نطاق الحدث المتكرر", + "entireSeries": "السلسلة بأكملها", + "singleOccurrence": "هذا الحدث", + "thisAndFollowingEvents": "هذا الحدث والأحداث التالية", + "thisAndFutureUnavailable": "غير مدعوم من موفّر الخدمة هذا.", + "thisAndFutureMoveUnavailable": "لا يمكن نقل هذا الحدث والأحداث التالية بأمان. اختر هذا الحدث أو السلسلة كاملة.", + "entireSeriesMoveUnavailable": "قاعدة التكرار غير متوفرة محليًا. انقل هذا الحدث بدلاً من ذلك.", + "copyEventAndDeleteOriginal": "هل تريد نسخ الحدث وحذف الأصل؟", + "copyEventMoveWarning": "لا يستطيع BusyMax نقل هذا الحدث مباشرةً من ⁨{source}⁩ إلى ⁨{destination}⁩. سينشئ النسخة أولاً ولن يحذف الأصل إلا بعد نجاح النسخ. ستتغير معرّفات الحدث؛ وقد تُعاد تعيين حالات استجابة الحاضرين وتُرسل دعوات أو إلغاءات؛ وقد لا تُنقل روابط الاجتماعات والمرفقات والتذكيرات والحقول الخاصة بموفّر الخدمة واستثناءات التكرار.", + "copyAndDelete": "نسخ وحذف", + "chooseRecurringEventScope": "اختر ما إذا كان هذا التغيير ينطبق على السلسلة بأكملها، أو هذا الحدث فقط، أو هذا الحدث والأحداث التالية.", "taskDueBeforeStart": "يجب ألا يكون موعد الاستحقاق قبل وقت البدء.", "taskStartDueTimeModeMismatch": "عيّن وقتًا لكل من البدء والاستحقاق، أو اجعل المهمة طوال اليوم.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskListCreateFailed": "تعذّر إنشاء قائمة المهام: ⁨{error}⁩", + "taskListRenameFailed": "تعذّر إعادة تسمية قائمة المهام: ⁨{error}⁩", + "taskListDeleteFailed": "تعذّر حذف قائمة المهام: ⁨{error}⁩", "unshare": "إلغاء المشاركة", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "readOnlyTaskListCannotRename": "قائمة المهام هذه للقراءة فقط ولا يمكن إعادة تسميتها.", + "taskListCannotDelete": "لا يمكن حذف قائمة المهام هذه باستخدام أذوناتك الحالية.", + "deleteTaskListConfirmation": "حذف \"⁨{title}⁩\" وجميع مهامها؟", + "unshareTaskListConfirmation": "إلغاء مشاركة \"⁨{title}⁩\" من هذا الحساب؟", + "taskStatus": "الحالة", + "taskStatusNone": "بلا حالة", "taskStatusNeedsAction": "تحتاج إلى إجراء", - "taskStatusInProcess": "تحت الإجراء", - "taskStatusCompleted": "مُكتمِل", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusInProcess": "قيد التنفيذ", + "taskStatusCompleted": "مكتملة", + "taskStatusCancelled": "ملغاة", + "completionPercent": "اكتمل بنسبة {percent}٪", + "completionDate": "تاريخ الإكمال", "priority": "الأولوية", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "لا توجد أي تذكيرات", - "editReminder": "Edit reminder", - "beforeTaskStarts": "قبل أن تبدأ المهمة", - "beforeTaskDue": "قبل اكتمال المهمة", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "تكرار كل", - "repeatOn": "Repeat on", - "repeatEnd": "نهاية التكرار", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", - "repeatOnMonthDaysSummary": "في يوم {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", - "repeatTimesSummary": "{count} مرات", - "repeatUntilSummary": "until {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", - "hideSubtasks": "إخْفِ المهام الفرعية", - "hideClosedSubtasks": "إخف المهام الفرعية المغلقة", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "priorityNone": "بلا أولوية", + "priorityHighValue": "الأولوية {priority} · عالية", + "priorityMediumValue": "الأولوية {priority} · متوسطة", + "priorityLowValue": "الأولوية {priority} · منخفضة", + "taskUrl": "URL المهمة", + "invalidTaskUrl": "أدخل عنوان URL مطلقًا يتضمن مخططه.", + "classification": "التصنيف", + "classificationPublic": "عند المشاركة، أظهر المهمة كاملة", + "classificationConfidential": "عند المشاركة، أظهر الانشغال فقط", + "classificationPrivate": "عند المشاركة، أخفِ هذه المهمة", + "pinTask": "تثبيت المهمة", + "reminders": "التذكيرات", + "noReminders": "لا توجد تذكيرات", + "editReminder": "تعديل التذكير", + "beforeTaskStarts": "قبل بدء المهمة", + "beforeTaskDue": "قبل موعد استحقاق المهمة", + "afterTaskStarts": "بعد بدء المهمة", + "afterTaskDue": "بعد استحقاق المهمة", + "relativeToTaskStart": "بالنسبة إلى تاريخ بدء المهمة", + "relativeToTaskDue": "بالنسبة إلى تاريخ استحقاق المهمة", + "reminderTimeOfDay": "وقت اليوم", + "absoluteReminder": "في تاريخ ووقت", + "reminderAmount": "الكمية", + "reminderUnitSeconds": "ثوانٍ", + "reminderUnitMinutes": "دقائق", + "reminderUnitHours": "ساعات", + "reminderUnitDays": "أيام", + "reminderUnitWeeks": "أسابيع", + "reminderAtTaskStart": "عند بدء المهمة", + "reminderAtTaskDue": "عند وقت استحقاق المهمة", + "unsupportedReminder": "يُحتفظ بنوع هذا التذكير، لكن لا يمكن تعديل وقته.", + "relatedRemindersTitle": "الاحتفاظ بالتذكيرات المرتبطة؟", + "relatedRemindersDescription": "يحتوي هذا التاريخ على {count} من التذكيرات المرتبطة. هل تريد الاحتفاظ بها في تاريخها ووقتها الحاليين؟", + "discardRelatedReminders": "تجاهل التذكيرات", + "keepRelatedReminders": "الاحتفاظ بالتذكيرات", + "repeatEvery": "الفاصل الزمني", + "repeatOn": "التكرار في", + "repeatEnd": "إنهاء التكرار", + "repeatNever": "مطلقًا", + "repeatUntil": "في تاريخ", + "repeatAfter": "بعد عدد من التكرارات", + "repeatCount": "عدد التكرارات", + "repeatDayOfMonth": "أيام الشهر", + "repeatMonths": "الأشهر", + "repeatOrdinal": "ترتيب يوم الأسبوع", + "repeatSpecificDays": "أيام محددة", + "repeatFirst": "الأول", + "repeatSecond": "الثاني", + "repeatThird": "الثالث", + "repeatFourth": "الرابع", + "repeatFifth": "الخامس", + "repeatSecondToLast": "ما قبل الأخير", + "repeatLast": "الأخير", + "repeatAnyDay": "اليوم", + "repeatWeekday": "يوم من أيام الأسبوع", + "repeatWeekendDay": "يوم عطلة نهاية الأسبوع", + "repeatOrdinalDaySummary": "{dayKey, select, MO{اثنين} TU{ثلاثاء} WE{أربعاء} TH{خميس} FR{جمعة} SA{سبت} SU{أحد} day{يوم} weekday{يوم من أيام الأسبوع} weekend{يوم من عطلة نهاية الأسبوع} other{{day}}}", + "repeatEveryDays": "{count, plural, one{كل يوم} two{كل يومين} few{كل {count} أيام} many{كل {count} يومًا} other{كل {count} يوم}}", + "repeatEveryWeeks": "{count, plural, one{كل أسبوع} two{كل أسبوعين} few{كل {count} أسابيع} many{كل {count} أسبوعًا} other{كل {count} أسبوع}}", + "repeatEveryMonths": "{count, plural, one{كل شهر} two{كل شهرين} few{كل {count} أشهر} many{كل {count} شهرًا} other{كل {count} شهر}}", + "repeatEveryYears": "{count, plural, one{كل سنة} two{كل سنتين} few{كل {count} سنوات} many{كل {count} سنة} other{كل {count} سنة}}", + "repeatOnDaysSummary": "في {days}", + "repeatOnMonthDaysSummary": "في اليوم {days} من الشهر", + "repeatOnOrdinalSummary": "{position, select, first{في أول {days}} second{في ثاني {days}} third{في ثالث {days}} fourth{في رابع {days}} fifth{في خامس {days}} secondToLast{في {days} قبل الأخير} last{في آخر {days}} other{في {days}}}", + "repeatInMonthsSummary": "في {months}", + "repeatTimesSummary": "{count, plural, one{مرة واحدة} two{مرتين} few{{count} مرات} many{{count} مرة} other{{count} مرة}}", + "repeatUntilSummary": "حتى ⁨{date}⁩", + "unsupportedRecurrencePreserved": "تستخدم قاعدة التكرار هذه خيارات لا يغيّرها هذا المحرر.", + "recurrenceUnsupportedByProvider": "لا يمكن استخدام هذا التكرار مع ⁨{provider}⁩.", + "duplicateTask": "تكرار المهمة", + "taskDuplicated": "تم تكرار المهمة.", + "taskDuplicateFailed": "تعذّر تكرار المهمة: ⁨{error}⁩", + "hideSubtasks": "إخفاء المهام الفرعية", + "hideClosedSubtasks": "إخفاء المهام الفرعية المغلقة", + "reminderUnit": "الوحدة", + "attendeeRequired": "مطلوب", + "attendeeOptional": "اختياري", + "meetingSection": "الاجتماع", + "addGoogleMeet": "إضافة Google Meet", + "addTeamsMeeting": "إضافة اجتماع Microsoft Teams", + "onlineMeetingAdded": "تمت إضافة الاجتماع عبر الإنترنت", + "requestResponses": "طلب الردود", + "requestResponsesDescription": "اطلب من المدعوين الرد على الدعوة.", + "hideGuestList": "إخفاء قائمة المدعوين", + "hideGuestListDescription": "لا يمكن للمدعوين رؤية المدعوين الآخرين.", + "allowNewTimeProposals": "السماح باقتراح أوقات جديدة", + "allowNewTimeProposalsDescription": "يمكن للمدعوين اقتراح وقت مختلف للاجتماع.", + "notifyGuestsTitle": "إبلاغ المدعوين؟", + "notifyGuestsSaveMessage": "يضم هذا الاجتماع مدعوين. هل تريد إرسال الدعوات أو تحديثات الحدث عند حفظه؟", + "notifyGuestsDeleteMessage": "يضم هذا الاجتماع مدعوين. هل تريد إرسال إلغاء عند حذفه؟", + "sendUpdates": "إرسال التحديثات", + "sendCancellation": "إرسال الإلغاء", + "doNotSend": "عدم الإرسال", + "microsoftNotifyGuestsSaveTitle": "حفظ الاجتماع؟", + "microsoftNotifyGuestsSaveMessage": "سترسل Microsoft الدعوات أو تحديثات الحدث إلى المدعوين.", + "microsoftNotifyGuestsDeleteTitle": "حذف الاجتماع؟", + "microsoftNotifyGuestsDeleteMessage": "سترسل Microsoft إلغاءً إلى المدعوين.", + "organizer": "المنظّم", + "yourResponse": "ردك", + "guestResponses": "ردود المدعوين", + "respond": "الرد", + "acceptInvitation": "قبول", + "tentativeInvitation": "مبدئي", + "declineInvitation": "رفض", + "joinMeeting": "الانضمام إلى الاجتماع", + "responseAccepted": "مقبول", + "responseTentative": "مبدئي", + "responseDeclined": "مرفوض", + "responseNeedsAction": "بانتظار الرد", + "responseNotResponded": "لم يتم الرد", + "responseOrganizer": "المنظّم", + "invitationResponseFailed": "تعذّر إرسال ردك: ⁨{error}⁩", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "تعذّر فتح رابط الاجتماع.", + "setCustomCalendarName": "تعيين اسم مخصص", + "setAction": "تعيين", + "removeFromMyCalendars": "إزالة من تقاويمي", + "removeAction": "إزالة", + "removeCalendarConfirmation": "هل تريد إزالة \"⁨{title}⁩\" من قائمة تقويم Google؟ لن يتم حذف التقويم المشترك أو أحداثه.", + "calendarCannotRemove": "لا يمكن حذف هذا التقويم أو إزالته من هذا الحساب.", + "calendarPendingChangesPreventRemoval": "انتظر حتى تنتهي مزامنة التغييرات المعلقة لهذا التقويم قبل حذفه أو إزالته.", + "calendarSubscriptions": "اشتراكات التقويم", + "calendarSubscriptionsDescription": "أضف تقاويم للقراءة فقط يتم تحديثها من عنوان WebCal آمن.", + "addCalendarSubscription": "إضافة اشتراك تقويم", + "subscriptionName": "الاسم المحلي", + "subscriptionUrl": "عنوان URL للاشتراك", + "subscriptionUrlHelp": "أدخل عنوان HTTPS أو webcal. يحتفظ BusyMax بعنوان URL الكامل في التخزين الآمن.", + "subscriptionUrlInvalid": "أدخل عنوان HTTPS أو webcal صالحًا من دون معلومات مستخدم أو جزء.", + "subscriptionColor": "اللون المحلي", + "subscriptionColorHelp": "استخدم لونًا من ستة أرقام مثل #3584E4.", + "subscriptionColorInvalid": "أدخل لونًا سداسيًا عشريًا من ستة أرقام.", + "subscriptionRefreshMode": "تكرار التحديث", + "subscriptionAutomatic": "تلقائي", + "subscriptionHourly": "كل ساعة", + "subscriptionSixHours": "كل ست ساعات", + "subscriptionDaily": "يوميًا", + "subscriptionSafeOrigin": "المصدر: ⁨{origin}⁩", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "أدخل عنوان URL صالحًا لمعاينة مصدره الآمن.", + "subscriptionReadOnly": "اشتراك للقراءة فقط", + "subscriptionNeverRefreshed": "لم يتم التحديث بعد", + "subscriptionLastRefresh": "آخر تحديث ناجح: ⁨{time}⁩", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "التحديث التالي: ⁨{time}⁩", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "محدّث", + "subscriptionStatusIssue": "مشكلة في التحديث: ⁨{code}⁩", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "تحديث الآن", + "unsubscribe": "إلغاء الاشتراك", + "unsubscribeCalendarTitle": "إلغاء الاشتراك من «⁨{name}⁩»؟", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "يؤدي هذا إلى إزالة الاشتراك المحلي والأحداث المخزنة مؤقتًا. لن يتغير التقويم المنشور.", + "addSubscriptionAction": "إضافة اشتراك", + "subscriptionOperationFailed": "فشل اشتراك التقويم: ⁨{error}⁩", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "الاشتراكات", + "calendarImport": "استيراد التقويم", + "calendarImportDescription": "حدد ملفًا، وراجع أحداثه، ثم اختر التقويم القابل للكتابة الذي ينبغي أن يستقبلها.", + "importIcsFile": "استيراد ملف ‎.ics", + "importIcsPreview": "استيراد أحداث التقويم", + "importEventsFound": "مجموعات أحداث قابلة للاستيراد: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "أحداث غير صالحة: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "تم الاستبعاد عمدًا: ⁨{fields}⁩", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "لا يوجد تقويم وجهة قابل للكتابة.", + "importDestinationCalendar": "تقويم الوجهة", + "importIcsConfirm": "استيراد الأحداث", + "importIcsComplete": "اكتمل الاستيراد", + "importQueued": "تم الاستيراد أو وضعه في قائمة الانتظار: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "تم تخطي التكرارات: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "مجموعات تكرار غير مدعومة: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "تعذّر استيراد ملف التقويم: ⁨{error}⁩", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "غير متصل", "networkOfflineDescription": "ستتم مزامنة التغييرات عند استعادة الاتصال.", - "networkOfflineTryAgain": "أنت غير متصل. اتصل بالإنترنت وحاول مرة أخرى." + "networkOfflineTryAgain": "أنت غير متصل. اتصل بالإنترنت وحاول مرة أخرى.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "في الأيام {days} من الشهر", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first} و{second}", + "repeatWeekdayListStart": "{first}، {rest}", + "repeatMonthDayListPair": "{first} و{second}", + "repeatMonthDayListStart": "{first}، {rest}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} و{second}", + "repeatYearlyMonthDayListStart": "{first}، {rest}", + "repeatYearlyMonthListPair": "{first} و{second}", + "repeatYearlyMonthListStart": "{first}، {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} في يوم {day} من {month}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} في يوم {day} من أشهر {months}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} في الأيام {days} من أشهر {months}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{في أول {days} من أشهر {months}} second{في ثاني {days} من أشهر {months}} third{في ثالث {days} من أشهر {months}} fourth{في رابع {days} من أشهر {months}} fifth{في خامس {days} من أشهر {months}} secondToLast{في {days} قبل الأخير من أشهر {months}} last{في آخر {days} من أشهر {months}} other{في {days} من أشهر {months}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency} في الأيام {days} من {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{في أول {days} من {month}} second{في ثاني {days} من {month}} third{في ثالث {days} من {month}} fourth{في رابع {days} من {month}} fifth{في خامس {days} من {month}} secondToLast{في {days} قبل الأخير من {month}} last{في آخر {days} من {month}} other{في {days} من {month}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7f9f4888..3121fb06 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,14 +1,31 @@ { "@@locale": "de", + "windowsSupport": "Hilfe", + "windowsThirdPartyLicenses": "Lizenzen von Drittanbietern", + "windowsSearch": "Suchen", + "windowsStartupDisabledByUser": "Vom Benutzer in den Windows-Einstellungen deaktiviert.", + "windowsStartupDisabledByPolicy": "Durch eine Windows-Richtlinie deaktiviert.", + "windowsStartupUnavailable": "Verfügbar, nachdem BusyMax aus einem MSIX-Paket installiert wurde.", + "windowsReminderExitNotice": "Erinnerungen enden, wenn BusyMax vollständig beendet wird. Lassen Sie es im Hintergrund laufen, um sie zu erhalten.", + "windowsProductVersionLabel": "Produktversion", + "windowsPackageVersionLabel": "Windows-Paketversion", + "windowsUnpackaged": "Nicht paketiert", + "windowsAgendaLoadMore": "Weitere Agendaeinträge laden", + "repeatWeeklyDaySummary": "{dayKey, select, MO{montags} TU{dienstags} WE{mittwochs} TH{donnerstags} FR{freitags} SA{samstags} SU{sonntags} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "an den Tagen {first} und {second} des Monats", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} an den Tagen {firstDay} und {secondDay} im {month}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} jeweils am {day}. im {firstMonth} und {secondMonth}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} jeweils an den Tagen {firstDay} und {secondDay} im {firstMonth} und {secondMonth}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} jeweils an den Tagen {days} im {firstMonth} und {secondMonth}", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Verbinden Sie Google-, Microsoft-, Apple-iCloud-Kalender- oder Nextcloud-Konten.", "googlePermissionsConsentNotice": "Wählen Sie auf dem Google-Berechtigungsbildschirm sowohl Kalender- als auch Aufgabenberechtigungen aus.", "googlePermissionsRequiredRetry": "Die Berechtigungen für Google Kalender und Google Tasks sind erforderlich. Versuchen Sie es erneut und aktivieren Sie beide Kontrollkästchen.", "finishSetup": "Einrichtung abschließen", "continueSetup": "Weiter", "onboardingSetupTitle": "BusyMax einrichten", "onboardingAccountsStepTitle": "Konten verbinden", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "Fügen Sie alle Konten hinzu, die Sie verwenden möchten. BusyMax synchronisiert unterstützte Kalender, Ereignisse, Aufgabenlisten und Aufgaben aus jedem Konto.", "onboardingPreferencesStepTitle": "Systemeinstellungen wählen", "onboardingPreferencesStepDescription": "Legen Sie Desktop-Verhalten, Erinnerungen, Benachrichtigungsdetails und Darstellung fest, bevor Sie Ihren Zeitplan öffnen.", "signInWithGoogle": "Mit Google anmelden", @@ -33,6 +50,43 @@ "calendarTasks": "Aufgaben", "calendar": "Kalender", "calendars": "Kalender", + "newCalendar": "Neuer Kalender", + "calendarColor": "Kalenderfarbe", + "calendarColorOption": "Farbe {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "Dieser Anbieter unterstützt die Kalenderverwaltung in BusyMax nicht.", + "primaryCalendarCannotDelete": "Der primäre Kalender kann nicht gelöscht werden.", + "calendarCreateFailed": "Kalender konnte nicht erstellt werden: {error}", + "calendarCreatedRefreshPending": "Der Kalender wurde erstellt, aber BusyMax konnte das Konto nicht aktualisieren. Er erscheint nach der nächsten Synchronisierung.", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "Kalender konnte nicht aktualisiert werden: {error}", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "Kalender konnte nicht gelöscht werden: {error}", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "Neuer Termin", "refreshCalendar": "Kalender aktualisieren", "openInProvider": "Beim Anbieter öffnen", @@ -52,13 +106,62 @@ "scheduleNoSearchResultsDescription": "Versuchen Sie es mit einer anderen Suche oder setzen Sie die aktuellen Filter zurück.", "refresh": "Aktualisieren", "trayOpenBusyMax": "BusyMax öffnen", + "trayShowBusyMax": "BusyMax anzeigen", + "trayNewEvent": "Neuer Termin…", + "trayNewTask": "Neue Aufgabe…", + "trayToday": "Heute", + "trayAllDay": "Ganztägig", + "trayNow": "Jetzt", + "trayCalendarEvent": "Kalendertermin", + "trayUntitledEvent": "Termin ohne Titel", + "trayNothingElseToday": "Heute nichts Weiteres", + "trayTasksDueToday": "{count, plural, =1{1 Aufgabe heute fällig} other{{count} Aufgaben heute fällig}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Heutige Agenda öffnen", + "traySyncNow": "Jetzt synchronisieren", + "traySyncing": "Synchronisierung…", + "trayNotConnected": "Nicht verbunden", + "trayNotYetSynced": "Noch nicht synchronisiert", + "trayLastSyncedJustNow": "Gerade synchronisiert", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Vor 1 Minute synchronisiert} other{Vor {count} Minuten synchronisiert}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Vor 1 Stunde synchronisiert} other{Vor {count} Stunden synchronisiert}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Vor 1 Tag synchronisiert} other{Vor {count} Tagen synchronisiert}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Einstellungen", + "trayQuitBusyMax": "BusyMax beenden", "agendaLoadMoreOverdue": "Weitere überfällige Aufgaben laden", "agendaLoadMoreNoDate": "Weitere Aufgaben ohne Datum laden", "viewDay": "Tag", "viewWeek": "Woche", "viewMonth": "Monat", "viewYear": "Jahr", - "viewAgenda": "Agenda", + "viewAgenda": "Tagesübersicht", "scheduleSettings": "Zeitplan", "scheduleDisplaySettings": "Zeitplananzeige", "scheduleDisplayHoursDescription": "In der Tages- und Wochenansicht wird zunächst dieser Zeitraum angezeigt. Frühere oder spätere Einträge erweitern ihn bei Bedarf.", @@ -132,12 +235,12 @@ "allTasks": "Alle Aufgaben", "tasksInList": "Aufgaben in {title}", "taskLists": "Aufgabenlisten", - "navigation": "Navigation", + "navigation": "Bedienung", "mainMenu": "Hauptmenü", "keyboardShortcuts": "Tastenkürzel", "shortcutGroupGeneral": "Allgemein", "shortcutKeyboardShortcutsDescription": "Diese Übersicht der Tastenkürzel anzeigen", - "shortcutGroupNavigation": "Navigation", + "shortcutGroupNavigation": "Bedienung", "shortcutNextPeriod": "Nächster Zeitraum", "shortcutNextPeriodDescription": "Nächste Woche in der Wochenansicht, nächster Monat in der Monatsansicht usw.", "shortcutPreviousPeriod": "Vorheriger Zeitraum", @@ -159,7 +262,7 @@ "aboutBusyMaxDescription": "Kalender und Aufgaben", "license": "Lizenz", "apacheLicenseName": "Apache License 2.0", - "website": "Website", + "website": "Webseite", "sourceCode": "Quellcode", "reportAnIssue": "Problem melden", "sendFeedback": "Feedback senden", @@ -175,7 +278,7 @@ "feedbackDetailedMessage": "Ausführliche Nachricht", "feedbackReplyEmail": "E-Mail-Adresse für Antworten (optional)", "feedbackIncludeTechnicalDetails": "Technische Details hinzufügen", - "feedbackTechnicalDetailsDisclosure": "Fügt nur die Version Ihres Linux-Betriebssystems und die Spracheinstellung der Anwendung hinzu. Es werden keine Protokolle, Kontodaten, Dateinamen oder anderen Diagnosedaten hinzugefügt.", + "feedbackTechnicalDetailsDisclosure": "Fügt nur Name und Version Ihres Betriebssystems sowie die Spracheinstellung der Anwendung hinzu. Es werden keine Protokolle, Kontodaten, Dateinamen oder anderen Diagnosedaten hinzugefügt.", "feedbackCategoryRequired": "Wählen Sie eine Kategorie aus.", "feedbackSubjectLengthError": "Der Betreff muss zwischen 3 und 120 Zeichen lang sein.", "feedbackMessageLengthError": "Die Nachricht muss zwischen 10 und 5.000 Zeichen lang sein.", @@ -208,7 +311,7 @@ } } }, - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "Dadurch werden zwischengespeicherte Aufgaben, Kalender, Termine, Erinnerungen und ausstehende Offline-Änderungen von diesem Gerät gelöscht. Nicht synchronisierte Änderungen gehen verloren. Kopien der Kalender, Termine, Aufgabenlisten und Aufgaben beim Anbieter werden nicht gelöscht.", "revokeGoogleAccess": "BusyMax-Zugriff auf dieses Google-Konto ebenfalls widerrufen", "revokeGoogleAccessDescription": "Vor einer erneuten Verbindung müssen Sie den Zugriff wieder gewähren.", "removeAccountAction": "Konto entfernen", @@ -224,7 +327,7 @@ "deleteList": "Liste löschen", "builtInMicrosoftList": "Integriert", "builtInMicrosoftListCannotRenameDelete": "Integrierte Microsoft To Do-Listen können nicht umbenannt oder gelöscht werden.", - "deleteListConfirmation": "\"{title}\" aus Google Tasks löschen?", + "deleteListConfirmation": "„{title}“ aus Google Tasks löschen?", "deleteEvent": "Termin löschen", "title": "Titel", "create": "Erstellen", @@ -273,7 +376,7 @@ "signInToEditTasks": "Melden Sie sich an, um Aufgaben zu bearbeiten.", "refreshTask": "Aufgabe aktualisieren", "primarySection": "Primär", - "statusSection": "Status", + "statusSection": "Aufgabenstatus", "openStatus": "Offen", "doneStatus": "Erledigt", "notes": "Notizen", @@ -301,12 +404,12 @@ "repeatYearly": "Jährlich", "importance": "Wichtigkeit", "importanceLow": "Niedrig", - "importanceNormal": "Normal", + "importanceNormal": "Mittel", "importanceHigh": "Hoch", "categories": "Kategorien", "scheduleSection": "Zeitplan", "dueGroup": "Fällig", - "startGroup": "Start", + "startGroup": "Beginn", "reminderGroup": "Erinnerung", "organizationSection": "Organisation", "actionsSection": "Aktionen", @@ -325,7 +428,7 @@ "etag": "ETag", "updated": "Aktualisiert", "parent": "Übergeordnete Aufgabe", - "position": "Position", + "position": "Reihenfolge", "webLink": "Weblink", "assignment": "Zuweisung", "localState": "Lokaler Status", @@ -333,7 +436,8 @@ "synced": "Synchronisiert", "account": "Konto", "sync": "Synchronisierung", - "manualFullSync": "Manuelle vollständige Synchronisierung", + "forceFullResync": "Vollständige Neusynchronisierung erzwingen", + "forceFullResyncDescription": "Lädt alle Daten aus jedem verbundenen Konto vollständig neu. Verwenden Sie diese Option nur zur Behebung von Synchronisierungsproblemen.", "runInBackgroundWhenClosed": "Nach dem Schließen des Fensters weiter ausführen", "showTrayIcon": "Symbol im Benachrichtigungsbereich anzeigen", "startMinimizedToTray": "Minimiert im Benachrichtigungsbereich starten", @@ -347,18 +451,23 @@ "notifyConflicts": "Benachrichtigungen bei Konflikten", "notifyDueToday": "Benachrichtigungen für heute fällige Aufgaben", "eventReminders": "Terminerinnerungen", + "onState": "Ein", "taskReminders": "Aufgabenerinnerungen", "notificationDetailLevel": "Detailgrad der Benachrichtigungen", "notificationDetailPrivate": "Privat", - "notificationDetailNormal": "Normal", + "notificationDetailNormal": "Standard", "quietHours": "Ruhezeiten", "quietHoursDescription": "Benachrichtigungen während dieses Zeitraums pausieren.", "quietHoursStart": "Beginn der Ruhezeit", "quietHoursEnd": "Ende der Ruhezeit", "notifications": "Benachrichtigungen", + "windowsNotificationsUnavailable": "Windows-Benachrichtigungen sind nicht verfügbar", + "windowsNotificationsUnpackaged": "Diese nicht paketierte Entwicklungsausführung kann keine Windows-Benachrichtigungen verwenden. Installieren Sie das testsignierte MSIX, um Erinnerungen zu testen.", + "windowsNotificationsInstalledFailure": "BusyMax konnte Windows-Benachrichtigungen nicht initialisieren. Erinnerungen werden erst angezeigt, wenn dieses Installationsproblem behoben ist.", "appearance": "Darstellung", "theme": "Design", - "themeSystem": "System", + "themeSystem": "Systemstandard", + "settingsSystem": "System", "themeLight": "Hell", "themeDark": "Dunkel", "themeFamily": "Designfamilie", @@ -403,7 +512,7 @@ "taskReminderNotificationBody": "Die Aufgabe ist bald fällig.", "notificationOpenAction": "Öffnen", "notificationSnoozeAction": "In 10 Minuten erinnern", - "notificationDismissAction": "Verwerfen", + "notificationDismissAction": "Schließen", "notificationDetailsHidden": "Details werden durch Datenschutzeinstellungen ausgeblendet.", "previousMonth": "Vorheriger Monat", "nextMonth": "Nächster Monat", @@ -432,44 +541,44 @@ "selectTimeZone": "Zeitzone auswählen", "searchLocations": "Orte suchen", "noLocationsFound": "Keine Orte gefunden", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "Dieses Feld ist erforderlich.", + "providerConnectionDescription": "Verbinden Sie Kalender und Aufgaben mit einem dieser Anbieter.", + "appleICloudProvider": "Apple-iCloud-Kalender", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloud-Aufgaben", + "addAppleICloudAccount": "Apple-iCloud-Kalenderkonto hinzufügen", + "addNextcloudAccount": "Nextcloud-Konto hinzufügen", + "waitingForAppleICloud": "Verbindung mit Apple iCloud wird hergestellt…", + "waitingForNextcloud": "Warten auf die Nextcloud-Autorisierung…", + "connectAppleICloudTitle": "Apple-iCloud-Kalender verbinden", + "appleAccountEmail": "E-Mail des Apple-Accounts", + "appleAppSpecificPassword": "App-spezifisches Passwort", + "appleAppSpecificPasswordHelp": "Erstellen Sie nach dem Aktivieren der Zwei-Faktor-Authentifizierung für Ihren Apple-Account ein app-spezifisches Passwort.", + "appleAppSpecificPasswordResetWarning": "Durch das Zurücksetzen Ihres Apple-Account-Passworts werden app-spezifische Passwörter widerrufen.", + "connectNextcloudTitle": "Nextcloud verbinden", + "nextcloudServerUrl": "Nextcloud-Server oder CalDAV-Adresse", + "nextcloudServerUrlHelp": "Geben Sie die URL Ihres Nextcloud-Servers ein oder fügen Sie die aus Nextcloud kopierte primäre CalDAV-Adresse ein.", + "nextcloudBrowserAuthorizationHelp": "BusyMax öffnet Ihren Browser. Genehmigen Sie dort den Zugriff und kehren Sie anschließend zu BusyMax zurück.", + "connectAccountAction": "Verbinden", + "cancelAccountConnection": "Verbindung abbrechen", + "nextcloudAccountRemovedRevokeFailed": "Das Konto wurde lokal entfernt, aber das Nextcloud-App-Passwort konnte nicht widerrufen werden.", + "davCachedOfflineNotice": "Kalender- und Aufgabendaten werden für die Offline-Nutzung lokal zwischengespeichert.", + "davReauthenticationRequired": "Verbinden Sie dieses Konto erneut, um die Synchronisierung fortzusetzen.", + "davTemporarilyUnavailable": "Dieses Konto ist vorübergehend nicht verfügbar.", + "davPermissionChanged": "Die Serverberechtigungen wurden geändert. Ausstehende Bearbeitungen sind pausiert.", + "davUnsupportedServer": "Dieser Server oder dieses Anbieterprofil wird nicht unterstützt.", + "collectionSettings": "Kalender und Aufgabenlisten", + "calendarContent": "Kalendertermine", + "taskContent": "Aufgaben", + "readOnlySharedCollection": "Schreibgeschützt", + "pendingLocally": "Lokal ausstehend", + "conflictBlocked": "Durch Konflikt blockiert", + "authenticationBlocked": "Bis zur erneuten Verbindung blockiert", + "operationFailed": "Vorgang fehlgeschlagen", + "keepServerVersion": "Serverversion behalten", + "reapplyLocalChange": "Lokale Änderung prüfen und erneut anwenden", + "duplicateLocalItem": "Als neues Element duplizieren", "deleteCalendarConfirmation": "\"{title}\" löschen?", "@deleteCalendarConfirmation": { "placeholders": { @@ -478,11 +587,11 @@ } } }, - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "Verbindungsstatus", + "davConnected": "Verbunden", + "davConnecting": "Verbindung wird hergestellt…", + "davSignedOut": "Abgemeldet", + "davLastSuccessfulSync": "Letzte erfolgreiche Synchronisierung: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -490,9 +599,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "Noch nicht synchronisiert", + "refreshCollections": "Kalender und Aufgabenlisten aktualisieren", + "nextcloudServerHost": "Serveradresse: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -500,12 +609,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "Terminkalender", + "collectionSupportsTasks": "Aufgabenliste", + "collectionSupportsEventsAndTasks": "Termine und Aufgaben", + "writableCollection": "Beschreibbar", + "sharedCollection": "Freigegeben", + "collectionLastSynced": "Zuletzt synchronisiert: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -513,7 +622,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "Synchronisierungsproblem: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -521,8 +630,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "Synchronisierungskonflikte", + "remoteChangedAt": "Server geändert: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -530,7 +639,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "Lokale Änderung: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -538,144 +647,366 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "conflictResolutionFailed": "Der Konflikt konnte nicht gelöst werden.", + "recurringEventScope": "Bereich des wiederkehrenden Termins", + "entireSeries": "Gesamte Serie", + "singleOccurrence": "Dieser Termin", + "thisAndFollowingEvents": "Dieser und die folgenden Termine", + "thisAndFutureUnavailable": "Von diesem Anbieter nicht unterstützt.", + "thisAndFutureMoveUnavailable": "Dieser Termin und die folgenden Termine können nicht sicher verschoben werden. Wählen Sie diesen Termin oder die gesamte Serie.", + "entireSeriesMoveUnavailable": "Die Wiederholungsregel ist lokal nicht verfügbar. Verschieben Sie stattdessen diesen Termin.", + "copyEventAndDeleteOriginal": "Termin kopieren und Original löschen?", + "copyEventMoveWarning": "BusyMax kann diesen Termin nicht direkt von {source} nach {destination} verschieben. Zuerst wird die Kopie erstellt; das Original wird erst nach erfolgreichem Kopieren gelöscht. Termin-IDs ändern sich; Antwortstatus von Teilnehmern können zurückgesetzt und Einladungen oder Absagen versendet werden; Konferenzlinks, Anhänge, Erinnerungen, anbieterspezifische Felder und Wiederholungsausnahmen werden möglicherweise nicht übernommen.", + "copyAndDelete": "Kopieren und löschen", + "chooseRecurringEventScope": "Wählen Sie, ob diese Änderung für die gesamte Serie, nur diesen Termin oder diesen und die folgenden Termine gilt.", "taskDueBeforeStart": "Die Fälligkeit darf nicht vor dem Beginn liegen.", - "taskStartDueTimeModeMismatch": "Lege für Beginn und Fälligkeit jeweils eine Uhrzeit fest oder mache die Aufgabe ganztägig.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskStartDueTimeModeMismatch": "Legen Sie für Beginn und Fälligkeit jeweils eine Uhrzeit fest oder machen Sie die Aufgabe ganztägig.", + "taskListCreateFailed": "Aufgabenliste konnte nicht erstellt werden: {error}", + "taskListRenameFailed": "Aufgabenliste konnte nicht umbenannt werden: {error}", + "taskListDeleteFailed": "Aufgabenliste konnte nicht gelöscht werden: {error}", "unshare": "Freigabe aufheben", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "readOnlyTaskListCannotRename": "Diese Aufgabenliste ist schreibgeschützt und kann nicht umbenannt werden.", + "taskListCannotDelete": "Diese Aufgabenliste kann mit Ihren aktuellen Berechtigungen nicht gelöscht werden.", + "deleteTaskListConfirmation": "„{title}“ und alle Aufgaben löschen?", + "unshareTaskListConfirmation": "Freigabe von „{title}“ für dieses Konto aufheben?", + "taskStatus": "Aufgabenstatus", + "taskStatusNone": "Kein Status", "taskStatusNeedsAction": "Handlungsbedarf", "taskStatusInProcess": "In Bearbeitung", - "taskStatusCompleted": "Fertiggestellt", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusCompleted": "Erledigt", + "taskStatusCancelled": "Abgebrochen", + "completionPercent": "{percent}% abgeschlossen", + "completionDate": "Abschlussdatum", "priority": "Priorität", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", + "priorityNone": "Keine Priorität", + "priorityHighValue": "Priorität {priority} · Hoch", + "priorityMediumValue": "Priorität {priority} · Mittel", + "priorityLowValue": "Priorität {priority} · Niedrig", + "taskUrl": "Aufgaben-URL", + "invalidTaskUrl": "Geben Sie eine absolute URL einschließlich ihres Schemas ein.", + "classification": "Klassifizierung", + "classificationPublic": "Bei Freigabe die vollständige Aufgabe anzeigen", + "classificationConfidential": "Bei Freigabe nur den Belegt-Status anzeigen", + "classificationPrivate": "Diese Aufgabe bei Freigabe ausblenden", + "pinTask": "Aufgabe anheften", + "reminders": "Erinnerungen", "noReminders": "Keine Erinnerungen", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Bevor die Aufgabe startet", - "beforeTaskDue": "Bevor die Aufgabe fällig ist", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", + "editReminder": "Erinnerung bearbeiten", + "beforeTaskStarts": "Vor Beginn der Aufgabe", + "beforeTaskDue": "Vor Fälligkeit der Aufgabe", + "afterTaskStarts": "Nach Beginn der Aufgabe", + "afterTaskDue": "Nach Fälligkeit der Aufgabe", + "relativeToTaskStart": "Relativ zum Startdatum der Aufgabe", + "relativeToTaskDue": "Relativ zum Fälligkeitsdatum der Aufgabe", + "reminderTimeOfDay": "Tageszeit", + "absoluteReminder": "Zu einem Datum und einer Uhrzeit", + "reminderAmount": "Menge", + "reminderUnitSeconds": "Sekunden", + "reminderUnitMinutes": "Minuten", + "reminderUnitHours": "Stunden", + "reminderUnitDays": "Tage", + "reminderUnitWeeks": "Wochen", + "reminderAtTaskStart": "Zu Beginn der Aufgabe", + "reminderAtTaskDue": "Zum Fälligkeitszeitpunkt der Aufgabe", + "unsupportedReminder": "Dieser Erinnerungstyp bleibt erhalten, aber seine Zeit kann nicht bearbeitet werden.", + "relatedRemindersTitle": "Verknüpfte Erinnerungen behalten?", + "relatedRemindersDescription": "Für dieses Datum gibt es {count} verknüpfte Erinnerungen. Sollen sie ihr aktuelles Datum und ihre aktuelle Uhrzeit behalten?", "discardRelatedReminders": "Erinnerungen verwerfen", "keepRelatedReminders": "Erinnerungen behalten", - "repeatEvery": "Wiederhole jeden", - "repeatOn": "Repeat on", + "repeatEvery": "Intervall", + "repeatOn": "Wiederholen an", "repeatEnd": "Wiederholung beenden", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "an {days}", - "repeatOnMonthDaysSummary": "am Tag {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", - "repeatTimesSummary": "{count} mal", + "repeatNever": "Nie", + "repeatUntil": "An einem Datum", + "repeatAfter": "Nach einer Anzahl von Wiederholungen", + "repeatCount": "Wiederholungen", + "repeatDayOfMonth": "Tage des Monats", + "repeatMonths": "Monate", + "repeatOrdinal": "Wochentagsposition", + "repeatSpecificDays": "Bestimmte Tage", + "repeatFirst": "Erste", + "repeatSecond": "Zweite", + "repeatThird": "Dritte", + "repeatFourth": "Vierte", + "repeatFifth": "Fünfte", + "repeatSecondToLast": "Vorletzte", + "repeatLast": "Letzte", + "repeatAnyDay": "Tag", + "repeatWeekday": "Wochentag", + "repeatWeekendDay": "Wochenendtag", + "repeatOrdinalDaySummary": "{dayKey, select, MO{Montag} TU{Dienstag} WE{Mittwoch} TH{Donnerstag} FR{Freitag} SA{Samstag} SU{Sonntag} day{Tag} weekday{Wochentag} weekend{Wochenendtag} other{{day}}}", + "repeatEveryDays": "Alle {count} Tage", + "repeatEveryWeeks": "Alle {count} Wochen", + "repeatEveryMonths": "Alle {count} Monate", + "repeatEveryYears": "Alle {count} Jahre", + "repeatOnDaysSummary": "{days}", + "repeatOnMonthDaysSummary": "am {days}. Tag", + "repeatOnOrdinalSummary": "{position, select, first{am ersten {days}} second{am zweiten {days}} third{am dritten {days}} fourth{am vierten {days}} fifth{am fünften {days}} secondToLast{am vorletzten {days}} last{am letzten {days}} other{an {days}}}", + "repeatInMonthsSummary": "in den Monaten {months}", + "repeatTimesSummary": "{count, plural, one{{count} Mal} other{{count}-mal}}", "repeatUntilSummary": "bis {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "unsupportedRecurrencePreserved": "Diese Wiederholungsregel verwendet Optionen, die dieser Editor nicht ändert.", + "recurrenceUnsupportedByProvider": "Diese Wiederholung kann nicht mit {provider} verwendet werden.", "duplicateTask": "Aufgabe duplizieren", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "taskDuplicated": "Aufgabe dupliziert.", + "taskDuplicateFailed": "Aufgabe konnte nicht dupliziert werden: {error}", "hideSubtasks": "Teilaufgaben ausblenden", "hideClosedSubtasks": "Geschlossene Teilaufgaben ausblenden", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", - "networkOffline": "Offline", + "reminderUnit": "Einheit", + "attendeeRequired": "Erforderlich", + "attendeeOptional": "Freiwillig", + "meetingSection": "Besprechung", + "addGoogleMeet": "Google Meet hinzufügen", + "addTeamsMeeting": "Microsoft-Teams-Besprechung hinzufügen", + "onlineMeetingAdded": "Onlinebesprechung hinzugefügt", + "requestResponses": "Antworten anfordern", + "requestResponsesDescription": "Bitten Sie Gäste, auf die Einladung zu antworten.", + "hideGuestList": "Gästeliste ausblenden", + "hideGuestListDescription": "Gäste können nicht sehen, wer sonst eingeladen wurde.", + "allowNewTimeProposals": "Neue Zeitvorschläge zulassen", + "allowNewTimeProposalsDescription": "Gäste können eine andere Besprechungszeit vorschlagen.", + "notifyGuestsTitle": "Gäste benachrichtigen?", + "notifyGuestsSaveMessage": "Diese Besprechung hat Gäste. Sollen beim Speichern Einladungen oder Terminaktualisierungen gesendet werden?", + "notifyGuestsDeleteMessage": "Diese Besprechung hat Gäste. Soll beim Löschen eine Absage gesendet werden?", + "sendUpdates": "Aktualisierungen senden", + "sendCancellation": "Absage senden", + "doNotSend": "Nicht senden", + "microsoftNotifyGuestsSaveTitle": "Besprechung speichern?", + "microsoftNotifyGuestsSaveMessage": "Microsoft sendet Einladungen oder Terminaktualisierungen an die Gäste.", + "microsoftNotifyGuestsDeleteTitle": "Besprechung löschen?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft sendet eine Absage an die Gäste.", + "organizer": "Organisator", + "yourResponse": "Ihre Antwort", + "guestResponses": "Gästeantworten", + "respond": "Antworten", + "acceptInvitation": "Annehmen", + "tentativeInvitation": "Mit Vorbehalt", + "declineInvitation": "Ablehnen", + "joinMeeting": "An Besprechung teilnehmen", + "responseAccepted": "Angenommen", + "responseTentative": "Mit Vorbehalt", + "responseDeclined": "Abgelehnt", + "responseNeedsAction": "Antwort ausstehend", + "responseNotResponded": "Nicht beantwortet", + "responseOrganizer": "Organisator", + "invitationResponseFailed": "Ihre Antwort konnte nicht gesendet werden: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "Der Besprechungslink konnte nicht geöffnet werden.", + "setCustomCalendarName": "Benutzerdefinierten Namen festlegen", + "setAction": "Festlegen", + "removeFromMyCalendars": "Aus meinen Kalendern entfernen", + "removeAction": "Entfernen", + "removeCalendarConfirmation": "„{title}“ aus Ihrer Google-Kalenderliste entfernen? Der freigegebene Kalender und seine Termine werden nicht gelöscht.", + "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.", + "calendarSubscriptions": "Kalenderabonnements", + "calendarSubscriptionsDescription": "Fügen Sie schreibgeschützte Kalender hinzu, die über eine sichere WebCal-URL aktualisiert werden.", + "addCalendarSubscription": "Kalenderabonnement hinzufügen", + "subscriptionName": "Lokaler Name", + "subscriptionUrl": "Abonnement-URL", + "subscriptionUrlHelp": "Geben Sie eine HTTPS- oder webcal-URL ein. BusyMax speichert die vollständige URL sicher.", + "subscriptionUrlInvalid": "Geben Sie eine gültige HTTPS- oder webcal-URL ohne Benutzerinformationen oder Fragment ein.", + "subscriptionColor": "Lokale Farbe", + "subscriptionColorHelp": "Verwenden Sie eine sechsstellige Farbe wie #3584E4.", + "subscriptionColorInvalid": "Geben Sie eine sechsstellige Hexadezimalfarbe ein.", + "subscriptionRefreshMode": "Aktualisierungshäufigkeit", + "subscriptionAutomatic": "Automatisch", + "subscriptionHourly": "Stündlich", + "subscriptionSixHours": "Alle sechs Stunden", + "subscriptionDaily": "Täglich", + "subscriptionSafeOrigin": "Quelle: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Geben Sie eine gültige URL ein, um ihre sichere Quelle anzuzeigen.", + "subscriptionReadOnly": "Schreibgeschütztes Abonnement", + "subscriptionNeverRefreshed": "Noch nicht aktualisiert", + "subscriptionLastRefresh": "Letzte erfolgreiche Aktualisierung: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Nächste Aktualisierung: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Aktuell", + "subscriptionStatusIssue": "Aktualisierungsproblem: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Jetzt aktualisieren", + "unsubscribe": "Abbestellen", + "unsubscribeCalendarTitle": "„{name}“ abbestellen?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "Dadurch werden das lokale Abonnement und die zwischengespeicherten Termine entfernt. Der veröffentlichte Kalender wird nicht geändert.", + "addSubscriptionAction": "Abonnement hinzufügen", + "subscriptionOperationFailed": "Kalenderabonnement fehlgeschlagen: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Abonnements", + "calendarImport": "Kalenderimport", + "calendarImportDescription": "Wählen Sie eine Datei aus, prüfen Sie ihre Termine und wählen Sie anschließend den beschreibbaren Kalender aus, der sie aufnehmen soll.", + "importIcsFile": "‎.ics-Datei importieren", + "importIcsPreview": "Kalendertermine importieren", + "importEventsFound": "Importierbare Terminserien: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Ungültige Termine: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Absichtlich ausgelassen: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "Kein beschreibbarer Zielkalender verfügbar.", + "importDestinationCalendar": "Zielkalender", + "importIcsConfirm": "Termine importieren", + "importIcsComplete": "Import abgeschlossen", + "importQueued": "Importiert oder in die Warteschlange gestellt: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Übersprungene Duplikate: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Nicht unterstützte Wiederholungsserien: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "Kalenderdatei konnte nicht importiert werden: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "networkOffline": "Ohne Verbindung", "networkOfflineDescription": "Änderungen werden synchronisiert, sobald die Verbindung wiederhergestellt ist.", - "networkOfflineTryAgain": "Sie sind offline. Stellen Sie eine Internetverbindung her und versuchen Sie es erneut." + "networkOfflineTryAgain": "Sie sind offline. Stellen Sie eine Internetverbindung her und versuchen Sie es erneut.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "an den Tagen {days} des Monats", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first} und {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} und {second}", + "repeatMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} und {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} und {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} am {day}. {month}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} jeweils am {day}. in {months}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} jeweils an den Tagen {days} in {months}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{jeweils am ersten} second{jeweils am zweiten} third{jeweils am dritten} fourth{jeweils am vierten} fifth{jeweils am fünften} secondToLast{jeweils am vorletzten} last{jeweils am letzten} other{jeweils an}} {days} in {months}", + "repeatYearlyOnMonthDaysSummary": "{frequency} an den Tagen {days} im {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{am ersten} second{am zweiten} third{am dritten} fourth{am vierten} fifth{am fünften} secondToLast{am vorletzten} last{am letzten} other{an}} {days} im {month}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 18abf8cc..7c50746b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,7 +1,51 @@ { "@@locale": "en", + "windowsSupport": "Support", + "windowsThirdPartyLicenses": "Third-party licenses", + "windowsSearch": "Search", + "windowsStartupDisabledByUser": "Disabled by the user in Windows Settings.", + "windowsStartupDisabledByPolicy": "Disabled by Windows policy.", + "windowsStartupUnavailable": "Available after BusyMax is installed from an MSIX package.", + "windowsReminderExitNotice": "Reminders stop when BusyMax is fully quit. Keep it running in the background to receive them.", + "windowsProductVersionLabel": "Product version", + "windowsPackageVersionLabel": "Windows package version", + "windowsUnpackaged": "Unpackaged", + "windowsAgendaLoadMore": "Load more agenda items", + "repeatWeeklyDaySummary": "{dayKey, select, MO{Monday} TU{Tuesday} WE{Wednesday} TH{Thursday} FR{Friday} SA{Saturday} SU{Sunday} other{{day}}}", + "@repeatWeeklyDaySummary": { + "description": "Sentence-form weekday used inside a weekly recurrence summary. {dayKey} is MO through SU; {day} is the locale-formatted fallback.", + "placeholders": {"dayKey": {"type": "String"}, "day": {"type": "String"}} + }, + "repeatOnTwoMonthDaysSummary": "on days {first} and {second}", + "@repeatOnTwoMonthDaysSummary": { + "description": "Sentence fragment for a monthly recurrence on exactly two dates.", + "placeholders": {"first": {"type": "String"}, "second": {"type": "String"}} + }, + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} on days {firstDay} and {secondDay} of {month}", + "@repeatYearlyOnTwoMonthDaysSummary": { + "description": "Complete yearly recurrence summary for exactly two dates in one month.", + "placeholders": {"frequency": {"type": "String"}, "month": {"type": "String"}, "firstDay": {"type": "String"}, "secondDay": {"type": "String"}} + }, + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} on day {day} of {firstMonth} and {secondMonth}", + "@repeatYearlyInTwoMonthsOnMonthDaySummary": { + "description": "Complete yearly recurrence summary for one date in exactly two months.", + "placeholders": {"frequency": {"type": "String"}, "firstMonth": {"type": "String"}, "secondMonth": {"type": "String"}, "day": {"type": "String"}} + }, + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} on days {firstDay} and {secondDay} of {firstMonth} and {secondMonth}", + "@repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": { + "description": "Complete yearly recurrence summary for exactly two dates in exactly two months.", + "placeholders": {"frequency": {"type": "String"}, "firstMonth": {"type": "String"}, "secondMonth": {"type": "String"}, "firstDay": {"type": "String"}, "secondDay": {"type": "String"}} + }, + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} on days {days} of {firstMonth} and {secondMonth}", + "@repeatYearlyInTwoMonthsOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for three or more dates in exactly two months.", + "placeholders": {"frequency": {"type": "String"}, "firstMonth": {"type": "String"}, "secondMonth": {"type": "String"}, "days": {"type": "String"}} + }, "appTitle": "BusyMax", "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "@connectGoogleAccount": { + "description": "Shown in account onboarding and settings; names the supported account providers." + }, "googlePermissionsConsentNotice": "On the Google permission screen, select both Calendar and Tasks permissions.", "googlePermissionsRequiredRetry": "Google Calendar and Google Tasks permissions are required. Please try again and select both checkboxes.", "finishSetup": "Finish setup", @@ -9,6 +53,9 @@ "onboardingSetupTitle": "Set Up BusyMax", "onboardingAccountsStepTitle": "Connect accounts", "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "@onboardingAccountsStepDescription": { + "description": "Onboarding explanation of the account data BusyMax synchronizes." + }, "onboardingPreferencesStepTitle": "Choose system settings", "onboardingPreferencesStepDescription": "Set desktop behavior, reminders, notification detail, and appearance before opening your schedule.", "signInWithGoogle": "Sign in with Google", @@ -33,6 +80,44 @@ "calendarTasks": "Tasks", "calendar": "Calendar", "calendars": "Calendars", + "newCalendar": "New calendar", + "calendarColor": "Calendar color", + "calendarColorOption": "Color {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "calendarManagementUnsupported": "This provider does not support calendar management in BusyMax.", + "primaryCalendarCannotDelete": "The primary calendar cannot be deleted.", + "calendarCreateFailed": "Could not create the calendar: {error}", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarCreatedRefreshPending": "The calendar was created, but BusyMax could not refresh the account. It will appear after the next sync.", + "calendarUpdateFailed": "Could not update the calendar: {error}", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "Could not delete the calendar: {error}", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "New event", "refreshCalendar": "Refresh calendar", "openInProvider": "Open in provider", @@ -41,7 +126,14 @@ "noCalendarsSynced": "No calendars synced yet.", "allDay": "All day", "moreItems": "+{count} more", - "@moreItems": {"placeholders": {"count": {"type": "int"}}}, + "@moreItems": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, "noEventsOrTasks": "No events or tasks", "scheduleLoading": "Loading schedule...", "scheduleUnavailable": "Schedule unavailable", @@ -53,6 +145,59 @@ "scheduleNoSearchResultsDescription": "Try a different search or clear the current filters.", "refresh": "Refresh", "trayOpenBusyMax": "Open BusyMax", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "Load more overdue tasks", "agendaLoadMoreNoDate": "Load more no-date tasks", "viewDay": "Day", @@ -117,7 +262,13 @@ "responseNotResponded": "Not responded", "responseOrganizer": "Organizer", "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "joinMeetingFailed": "Could not open the meeting link.", "description": "Description", "availabilityShowAs": "Availability / Show as", @@ -134,12 +285,33 @@ "formatUnderlineShortLabel": "U", "formatUnderlineTooltip": "Underline", "reminderMinutesBefore": "{minutes, plural, =1{1 minute before} other{{minutes} minutes before}}", - "@reminderMinutesBefore": {"placeholders": {"minutes": {"type": "int"}}}, + "@reminderMinutesBefore": { + "placeholders": { + "minutes": { + "type": "int", + "format": "decimalPattern" + } + } + }, "reminderAtStart": "At start", "reminderHoursBefore": "{hours, plural, =1{1 hour before} other{{hours} hours before}}", - "@reminderHoursBefore": {"placeholders": {"hours": {"type": "int"}}}, + "@reminderHoursBefore": { + "placeholders": { + "hours": { + "type": "int", + "format": "decimalPattern" + } + } + }, "reminderDaysBefore": "{days, plural, =1{1 day before} other{{days} days before}}", - "@reminderDaysBefore": {"placeholders": {"days": {"type": "int"}}}, + "@reminderDaysBefore": { + "placeholders": { + "days": { + "type": "int", + "format": "decimalPattern" + } + } + }, "availabilityFree": "Free", "availabilityTentative": "Tentative", "availabilityOutOfOffice": "Out of office", @@ -153,7 +325,13 @@ "tasks": "Tasks", "allTasks": "All tasks", "tasksInList": "Tasks in {title}", - "@tasksInList": {"placeholders": {"title": {"type": "String"}}}, + "@tasksInList": { + "placeholders": { + "title": { + "type": "String" + } + } + }, "taskLists": "Task lists", "navigation": "Navigation", "mainMenu": "Main Menu", @@ -198,7 +376,7 @@ "feedbackDetailedMessage": "Detailed message", "feedbackReplyEmail": "Reply email (optional)", "feedbackIncludeTechnicalDetails": "Include technical details", - "feedbackTechnicalDetailsDisclosure": "Adds only your Linux operating-system version and application locale. No logs, account data, file names, or other diagnostics are included.", + "feedbackTechnicalDetailsDisclosure": "Adds only your operating-system name and version and application locale. No logs, account data, file names, or other diagnostics are included.", "feedbackCategoryRequired": "Select a category.", "feedbackSubjectLengthError": "Subject must be between 3 and 120 characters.", "feedbackMessageLengthError": "Message must be between 10 and 5,000 characters.", @@ -209,7 +387,13 @@ "feedbackRejectedError": "The server rejected the submission. Review the fields and try again.", "feedbackServerError": "BusyStack could not accept your feedback right now. Your feedback has not been cleared; please try again.", "feedbackSuccess": "Feedback sent. Reference: {id}", - "@feedbackSuccess": {"placeholders": {"id": {"type": "String"}}}, + "@feedbackSuccess": { + "placeholders": { + "id": { + "type": "String" + } + } + }, "toggleSidebar": "Toggle Sidebar", "showSidebar": "Show sidebar panel", "hideSidebar": "Hide sidebar panel", @@ -225,7 +409,13 @@ "removingAccount": "Removing account…", "removeAccountDescription": "Stop syncing and remove this account’s data from this device.", "removeAccountTitle": "Remove {account} from BusyMax?", - "@removeAccountTitle": {"placeholders": {"account": {"type": "String"}}}, + "@removeAccountTitle": { + "placeholders": { + "account": { + "type": "String" + } + } + }, "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", "revokeGoogleAccess": "Also revoke BusyMax’s access to this Google Account", "revokeGoogleAccessDescription": "You will need to grant access again before reconnecting.", @@ -234,11 +424,29 @@ "accountRemovedGoogleRevokeFailed": "The account was removed from this device, but BusyMax could not revoke Google access. You can revoke it from your Google Account.", "newTaskList": "New task list", "taskListCreateFailed": "Could not create the task list: {error}", - "@taskListCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@taskListCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "taskListRenameFailed": "Could not rename the task list: {error}", - "@taskListRenameFailed": {"placeholders": {"error": {"type": "String"}}}, + "@taskListRenameFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "taskListDeleteFailed": "Could not delete the task list: {error}", - "@taskListDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@taskListDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "signInToViewTaskLists": "Sign in to view task lists.", "noTaskListsSynced": "No task lists synced yet.", "listActions": "List actions", @@ -252,11 +460,29 @@ "builtInMicrosoftList": "Built-in", "builtInMicrosoftListCannotRenameDelete": "Built-in Microsoft To Do lists cannot be renamed or deleted.", "deleteListConfirmation": "Delete \"{title}\" from Google Tasks?", - "@deleteListConfirmation": {"placeholders": {"title": {"type": "String"}}}, + "@deleteListConfirmation": { + "placeholders": { + "title": { + "type": "String" + } + } + }, "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "@deleteTaskListConfirmation": {"placeholders": {"title": {"type": "String"}}}, + "@deleteTaskListConfirmation": { + "placeholders": { + "title": { + "type": "String" + } + } + }, "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "@unshareTaskListConfirmation": {"placeholders": {"title": {"type": "String"}}}, + "@unshareTaskListConfirmation": { + "placeholders": { + "title": { + "type": "String" + } + } + }, "deleteEvent": "Delete Event", "title": "Title", "create": "Create", @@ -267,11 +493,29 @@ "listRefreshed": "List refreshed.", "allTasksRefreshed": "All accounts refreshed.", "exportedFile": "Exported to {path}", - "@exportedFile": {"placeholders": {"path": {"type": "String"}}}, + "@exportedFile": { + "placeholders": { + "path": { + "type": "String" + } + } + }, "exportFailed": "Export failed: {error}", - "@exportFailed": {"placeholders": {"error": {"type": "String"}}}, + "@exportFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "refreshFailed": "Refresh failed: {error}", - "@refreshFailed": {"placeholders": {"error": {"type": "String"}}}, + "@refreshFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "selectOrCreateTaskList": "Select or create a task list to begin.", "signInToViewTasks": "Sign in to view tasks.", "noTasks": "No tasks.", @@ -285,12 +529,22 @@ "noDate": "No date", "completed": "Completed", "duePrefix": "Due {date}", - "@duePrefix": {"placeholders": {"date": {"type": "String"}}}, + "@duePrefix": { + "placeholders": { + "date": { + "type": "String" + } + } + }, "dateTimeDisplay": "{date} · {time}", "@dateTimeDisplay": { "placeholders": { - "date": {"type": "String"}, - "time": {"type": "String"} + "date": { + "type": "String" + }, + "time": { + "type": "String" + } } }, "taskDetails": "Task details", @@ -307,20 +561,48 @@ "taskStatus": "Status", "taskStatusNone": "No status", "taskStatusNeedsAction": "Needs action", - "taskStatusInProcess": "In process", + "taskStatusInProcess": "In progress", "taskStatusCompleted": "Completed", "taskStatusCancelled": "Cancelled", "completionPercent": "{percent}% completed", - "@completionPercent": {"placeholders": {"percent": {"type": "int"}}}, + "@completionPercent": { + "placeholders": { + "percent": { + "type": "int", + "format": "decimalPattern" + } + } + }, "completionDate": "Completion date", "priority": "Priority", "priorityNone": "No priority", "priorityHighValue": "Priority {priority} · High", - "@priorityHighValue": {"placeholders": {"priority": {"type": "int"}}}, + "@priorityHighValue": { + "placeholders": { + "priority": { + "type": "int", + "format": "decimalPattern" + } + } + }, "priorityMediumValue": "Priority {priority} · Medium", - "@priorityMediumValue": {"placeholders": {"priority": {"type": "int"}}}, + "@priorityMediumValue": { + "placeholders": { + "priority": { + "type": "int", + "format": "decimalPattern" + } + } + }, "priorityLowValue": "Priority {priority} · Low", - "@priorityLowValue": {"placeholders": {"priority": {"type": "int"}}}, + "@priorityLowValue": { + "placeholders": { + "priority": { + "type": "int", + "format": "decimalPattern" + } + } + }, "taskUrl": "URL", "invalidTaskUrl": "Enter an absolute URL, including its scheme.", "classification": "Classification", @@ -345,6 +627,9 @@ "editReminder": "Edit reminder", "beforeTaskStarts": "Before the task starts", "beforeTaskDue": "Before the task is due", + "@beforeTaskDue": { + "description": "Reminder timing relative to the task's due time/deadline, not task completion." + }, "afterTaskStarts": "After the task starts", "afterTaskDue": "After the task is due", "relativeToTaskStart": "Relative to the task start date", @@ -363,7 +648,14 @@ "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", "relatedRemindersTitle": "Keep related reminders?", "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "@relatedRemindersDescription": {"placeholders": {"count": {"type": "int"}}}, + "@relatedRemindersDescription": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, "discardRelatedReminders": "Discard reminders", "keepRelatedReminders": "Keep reminders", "addGuest": "Add Guest", @@ -377,8 +669,14 @@ "repeatWeekly": "Weekly", "repeatMonthly": "Monthly", "repeatYearly": "Yearly", - "repeatEvery": "Repeat every", + "repeatEvery": "Interval", + "@repeatEvery": { + "description": "Standalone label for the numeric interval used by daily, weekly, monthly, and yearly recurrence." + }, "repeatOn": "Repeat on", + "@repeatOn": { + "description": "Recurrence editor label introducing the selected weekdays, month days, or ordinal weekday." + }, "repeatEnd": "End repeat", "repeatNever": "Never", "repeatUntil": "On date", @@ -398,27 +696,117 @@ "repeatAnyDay": "Day", "repeatWeekday": "Weekday", "repeatWeekendDay": "Weekend day", + "repeatOrdinalDaySummary": "{dayKey, select, MO{Monday} TU{Tuesday} WE{Wednesday} TH{Thursday} FR{Friday} SA{Saturday} SU{Sunday} day{day} weekday{weekday} weekend{weekend day} other{{day}}}", + "@repeatOrdinalDaySummary": { + "description": "Sentence-form day label used inside ordinal recurrence summaries. {dayKey} is MO through SU, day, weekday, or weekend; {day} is the locale-formatted weekday fallback.", + "placeholders": { + "dayKey": {"type": "String"}, + "day": {"type": "String"} + } + }, "repeatEveryDays": "Every {count} days", - "@repeatEveryDays": {"placeholders": {"count": {"type": "int"}}}, + "@repeatEveryDays": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, "repeatEveryWeeks": "Every {count} weeks", - "@repeatEveryWeeks": {"placeholders": {"count": {"type": "int"}}}, + "@repeatEveryWeeks": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, "repeatEveryMonths": "Every {count} months", - "@repeatEveryMonths": {"placeholders": {"count": {"type": "int"}}}, + "@repeatEveryMonths": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, "repeatEveryYears": "Every {count} years", - "@repeatEveryYears": {"placeholders": {"count": {"type": "int"}}}, + "@repeatEveryYears": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, "repeatOnDaysSummary": "on {days}", - "@repeatOnDaysSummary": {"placeholders": {"days": {"type": "String"}}}, + "@repeatOnDaysSummary": { + "description": "Sentence fragment appended to a recurrence summary; {days} is a localized list of weekdays.", + "placeholders": { + "days": { + "type": "String" + } + } + }, "repeatOnMonthDaysSummary": "on day {days}", - "@repeatOnMonthDaysSummary": {"placeholders": {"days": {"type": "String"}}}, - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "@repeatOnOrdinalSummary": {"placeholders": {"ordinal": {"type": "String"}, "days": {"type": "String"}}}, + "@repeatOnMonthDaysSummary": { + "description": "Sentence fragment appended to a recurrence summary; {days} is a localized list of day-of-month numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatOnOrdinalSummary": "{position, select, first{on the first {days}} second{on the second {days}} third{on the third {days}} fourth{on the fourth {days}} fifth{on the fifth {days}} secondToLast{on the second to last {days}} last{on the last {days}} other{on {days}}}", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, "repeatInMonthsSummary": "in {months}", - "@repeatInMonthsSummary": {"placeholders": {"months": {"type": "String"}}}, - "repeatTimesSummary": "{count} times", - "@repeatTimesSummary": {"placeholders": {"count": {"type": "int"}}}, + "@repeatInMonthsSummary": { + "description": "Sentence fragment for selected recurrence months; {months} is a localized list of month names.", + "placeholders": { + "months": { + "type": "String" + } + } + }, + "repeatTimesSummary": "{count, plural, one{{count} time} other{{count} times}}", + "@repeatTimesSummary": { + "description": "Sentence fragment summarizing a finite recurrence count.", + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, "repeatUntilSummary": "until {date}", - "@repeatUntilSummary": {"placeholders": {"date": {"type": "String"}}}, + "@repeatUntilSummary": { + "description": "Sentence fragment for the recurrence end date.", + "placeholders": { + "date": { + "type": "String" + } + } + }, "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "This recurrence cannot be used with {provider}.", + "@recurrenceUnsupportedByProvider": { + "placeholders": { + "provider": { + "type": "String" + } + } + }, "importance": "Importance", "importanceLow": "Low", "importanceNormal": "Normal", @@ -439,19 +827,34 @@ "duplicateTask": "Duplicate task", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", - "@taskDuplicateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@taskDuplicateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "hideSubtasks": "Hide subtasks", "hideClosedSubtasks": "Hide closed subtasks", "moveToTop": "Move to top", "deleteTask": "Delete Task", "newSubtask": "New subtask", "deleteTaskConfirmation": "Delete \"{title}\"?", - "@deleteTaskConfirmation": {"placeholders": {"title": {"type": "String"}}}, + "@deleteTaskConfirmation": { + "placeholders": { + "title": { + "type": "String" + } + } + }, "metadata": "Metadata", "id": "ID", "etag": "ETag", "updated": "Updated", "parent": "Parent", + "@parent": { + "description": "Metadata label for the immediate parent task of a subtask." + }, "position": "Position", "webLink": "Web link", "assignment": "Assignment", @@ -460,7 +863,14 @@ "synced": "Synced", "account": "Account", "sync": "Sync", - "manualFullSync": "Manual full sync", + "forceFullResync": "Force full resync", + "@forceFullResync": { + "description": "Troubleshooting action that forces a complete resynchronization of every connected account." + }, + "forceFullResyncDescription": "Completely reload data from every connected account. Use this only to troubleshoot synchronization problems.", + "@forceFullResyncDescription": { + "description": "Explanation shown above the force-full-resync troubleshooting action in Diagnostics." + }, "runInBackgroundWhenClosed": "Continue running when the window is closed", "showTrayIcon": "Show tray icon", "startMinimizedToTray": "Start minimized to the tray", @@ -470,11 +880,18 @@ "requiresTrayIcon": "Requires the tray icon.", "syncComplete": "Sync complete.", "syncFailed": "Sync failed: {error}", - "@syncFailed": {"placeholders": {"error": {"type": "String"}}}, + "@syncFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "notifySyncFailures": "Notifications on sync failure", "notifyConflicts": "Notifications on conflicts", "notifyDueToday": "Due-today notifications", "eventReminders": "Event reminders", + "onState": "On", "taskReminders": "Task reminders", "notificationDetailLevel": "Notification detail level", "notificationDetailPrivate": "Private", @@ -484,9 +901,16 @@ "quietHoursStart": "Quiet hours start", "quietHoursEnd": "Quiet hours end", "notifications": "Notifications", + "windowsNotificationsUnavailable": "Windows notifications are unavailable", + "windowsNotificationsUnpackaged": "This unpackaged development run cannot use Windows notifications. Install the test-signed MSIX to test reminders.", + "windowsNotificationsInstalledFailure": "BusyMax could not initialize Windows notifications. Reminders will not appear until this installation problem is resolved.", "appearance": "Appearance", "theme": "Theme", "themeSystem": "System", + "settingsSystem": "System", + "@settingsSystem": { + "description": "Standalone noun used for the System section in the Settings sidebar and page heading." + }, "themeLight": "Light", "themeDark": "Dark", "themeFamily": "Theme family", @@ -500,7 +924,13 @@ "apiInspectorDisabled": "Show API inspector", "googleTasksApi": "Google Tasks API", "discoveryRevision": "Discovery revision: {revision}", - "@discoveryRevision": {"placeholders": {"revision": {"type": "String"}}}, + "@discoveryRevision": { + "placeholders": { + "revision": { + "type": "String" + } + } + }, "implementedMethods": "Implemented methods", "supportsTasksScopes": "Supports tasks and tasks.readonly scopes", "requiresTasksScope": "Requires tasks scope", @@ -509,11 +939,30 @@ "noBlockedPendingOperations": "No blocked pending operations.", "operationActions": "Operation actions", "pendingOpListId": "list={id}", - "@pendingOpListId": {"placeholders": {"id": {"type": "String"}}}, + "@pendingOpListId": { + "placeholders": { + "id": { + "type": "String" + } + } + }, "pendingOpTaskId": "task={id}", - "@pendingOpTaskId": {"placeholders": {"id": {"type": "String"}}}, + "@pendingOpTaskId": { + "placeholders": { + "id": { + "type": "String" + } + } + }, "pendingOpAttempts": "attempts={count}", - "@pendingOpAttempts": {"placeholders": {"count": {"type": "int"}}}, + "@pendingOpAttempts": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, "retry": "Retry", "discard": "Discard", "discardChangesAction": "Discard", @@ -528,13 +977,32 @@ "pendingOperationDiscarded": "Pending operation discarded.", "syncFailureNotificationTitle": "BusyMax sync failed", "syncFailureNotificationBody": "Background sync failed. {message}", - "@syncFailureNotificationBody": {"placeholders": {"message": {"type": "String"}}}, + "@syncFailureNotificationBody": { + "placeholders": { + "message": { + "type": "String" + } + } + }, "conflictNotificationTitle": "BusyMax sync conflict", "conflictNotificationBody": "A pending local change was blocked. {summary}", - "@conflictNotificationBody": {"placeholders": {"summary": {"type": "String"}}}, + "@conflictNotificationBody": { + "placeholders": { + "summary": { + "type": "String" + } + } + }, "dueTodayNotificationTitle": "Tasks due today", "dueTodayNotificationBody": "{count, plural, =1{One task is due today.} other{{count} tasks are due today.}}", - "@dueTodayNotificationBody": {"placeholders": {"count": {"type": "int"}}}, + "@dueTodayNotificationBody": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, "eventReminderNotificationTitle": "Event reminder", "taskReminderNotificationTitle": "Task reminder", "eventReminderNotificationBody": "Event starts soon.", @@ -542,6 +1010,9 @@ "notificationOpenAction": "Open", "notificationSnoozeAction": "Snooze 10 minutes", "notificationDismissAction": "Dismiss", + "@notificationDismissAction": { + "description": "Non-destructive action that closes or dismisses a desktop notification. Translate distinctly from reject, discard, or cancel." + }, "notificationDetailsHidden": "Details are hidden by privacy settings.", "previousMonth": "Previous month", "nextMonth": "Next month", @@ -550,10 +1021,24 @@ "nextYear": "Next year", "openYearView": "Open year view", "weekNumberTooltip": "Week {number}", - "@weekNumberTooltip": {"placeholders": {"number": {"type": "int"}}}, + "@weekNumberTooltip": { + "placeholders": { + "number": { + "type": "int", + "format": "decimalPattern" + } + } + }, "resizeAllDayPanel": "Resize the all-day panel", "scheduleItemCount": "{count, plural, =1{1 item} other{{count} items}}", - "@scheduleItemCount": {"placeholders": {"count": {"type": "int"}}}, + "@scheduleItemCount": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, "readOnlyCalendar": "This calendar is read-only.", "selectTimeZone": "Select Timezone", "searchLocations": "Search locations", @@ -601,36 +1086,402 @@ "davConnecting": "Connecting…", "davSignedOut": "Signed out", "davLastSuccessfulSync": "Last successful sync: {time}", - "@davLastSuccessfulSync": {"placeholders": {"time": {"type": "String"}}}, + "@davLastSuccessfulSync": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "davNeverSynced": "Not synchronized yet", "refreshCollections": "Refresh calendars and task lists", "nextcloudServerHost": "Server: {host}", - "@nextcloudServerHost": {"placeholders": {"host": {"type": "String"}}}, + "@nextcloudServerHost": { + "placeholders": { + "host": { + "type": "String" + } + } + }, "collectionSupportsEvents": "Event calendar", "collectionSupportsTasks": "Task list", "collectionSupportsEventsAndTasks": "Events and tasks", "writableCollection": "Writable", "sharedCollection": "Shared", "collectionLastSynced": "Last synchronized: {time}", - "@collectionLastSynced": {"placeholders": {"time": {"type": "String"}}}, + "@collectionLastSynced": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "collectionSyncError": "Sync issue: {code}", - "@collectionSyncError": {"placeholders": {"code": {"type": "String"}}}, + "@collectionSyncError": { + "placeholders": { + "code": { + "type": "String" + } + } + }, "syncConflicts": "Synchronization conflicts", "remoteChangedAt": "Server changed: {time}", - "@remoteChangedAt": {"placeholders": {"time": {"type": "String"}}}, + "@remoteChangedAt": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "localPendingEdit": "Local edit: {summary}", - "@localPendingEdit": {"placeholders": {"summary": {"type": "String"}}}, + "@localPendingEdit": { + "placeholders": { + "summary": { + "type": "String" + } + } + }, "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "singleOccurrence": "This event", + "thisAndFollowingEvents": "This and following events", + "thisAndFutureUnavailable": "Not supported by this provider.", + "thisAndFutureMoveUnavailable": "This and following events cannot be moved safely. Choose this event or the entire series.", + "entireSeriesMoveUnavailable": "The recurrence rule is not available locally. Move this event instead.", + "copyEventAndDeleteOriginal": "Copy event and delete original?", + "copyEventMoveWarning": "BusyMax cannot move this event directly from {source} to {destination}. It will create the copy first and delete the original only after the copy succeeds. Event IDs will change; attendee response statuses may reset and invitations or cancellations may be sent; conference links, attachments, reminders, provider-specific fields, and recurrence exceptions may not carry over.", + "@copyEventMoveWarning": { + "placeholders": { + "source": { + "type": "String" + }, + "destination": { + "type": "String" + } + } + }, + "copyAndDelete": "Copy and delete", + "chooseRecurringEventScope": "Choose whether this change applies to the entire series, only this occurrence, or this and following events.", + "@chooseRecurringEventScope": { + "description": "Explains the three recurring-event scope choices: entire series, one occurrence, or this and following events." + }, "taskDueBeforeStart": "Due must not be before start.", "taskStartDueTimeModeMismatch": "Set times for both start and due, or make the task all day.", "deleteCalendarConfirmation": "Delete \"{title}\"?", - "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}}, + "@deleteCalendarConfirmation": { + "placeholders": { + "title": { + "type": "String" + } + } + }, + "setCustomCalendarName": "Set custom name", + "setAction": "Set", + "removeFromMyCalendars": "Remove from my calendars", + "removeAction": "Remove", + "removeCalendarConfirmation": "Remove \"{title}\" from your Google Calendar list? The shared calendar and its events will not be deleted.", + "@removeCalendarConfirmation": { + "placeholders": { + "title": { + "type": "String" + } + } + }, + "calendarCannotRemove": "This calendar cannot be deleted or removed from this account.", + "calendarPendingChangesPreventRemoval": "Wait for this calendar’s pending changes to finish syncing before deleting or removing it.", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Subscriptions", + "calendarImport": "Calendar import", + "calendarImportDescription": "Select a file, review its events, then choose the writable calendar that should receive them.", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "Offline", "networkOfflineDescription": "Changes will sync when the connection is restored.", - "networkOfflineTryAgain": "You’re offline. Connect to the internet and try again." + "networkOfflineTryAgain": "You’re offline. Connect to the internet and try again.", + "repeatOnMonthDaysSummaryMultiple": "on days {days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first} and {second}", + "@repeatWeekdayListPair": { + "description": "Joins the final two weekday names in a recurrence summary.", + "placeholders": { + "first": {"type": "String"}, + "second": {"type": "String"} + } + }, + "repeatWeekdayListStart": "{first}, {rest}", + "@repeatWeekdayListStart": { + "description": "Prepends a weekday name to an already localized recurrence weekday list.", + "placeholders": { + "first": {"type": "String"}, + "rest": {"type": "String"} + } + }, + "repeatMonthDayListPair": "{first} and {second}", + "@repeatMonthDayListPair": { + "description": "Joins the final two day-of-month values in a monthly recurrence summary.", + "placeholders": { + "first": {"type": "String"}, + "second": {"type": "String"} + } + }, + "repeatMonthDayListStart": "{first}, {rest}", + "@repeatMonthDayListStart": { + "description": "Prepends a day-of-month value to an already localized monthly recurrence day list.", + "placeholders": { + "first": {"type": "String"}, + "rest": {"type": "String"} + } + }, + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "@repeatYearlyMonthValue": { + "description": "Month value used in yearly recurrence sentences. Locales may select an inflected form with {monthKey}; {month} is the CLDR abbreviated month fallback.", + "placeholders": { + "month": {"type": "String"}, + "monthKey": {"type": "String"} + } + }, + "repeatYearlyMonthDayListPair": "{first} and {second}", + "@repeatYearlyMonthDayListPair": { + "description": "Joins the final two localized day-of-month values in a yearly recurrence summary.", + "placeholders": { + "first": {"type": "String"}, + "second": {"type": "String"} + } + }, + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "@repeatYearlyMonthDayListStart": { + "description": "Prepends a localized day-of-month value to an already localized yearly recurrence day list.", + "placeholders": { + "first": {"type": "String"}, + "rest": {"type": "String"} + } + }, + "repeatYearlyMonthListPair": "{first} and {second}", + "@repeatYearlyMonthListPair": { + "description": "Joins the final two localized month names in a yearly recurrence summary.", + "placeholders": { + "first": {"type": "String"}, + "second": {"type": "String"} + } + }, + "repeatYearlyMonthListStart": "{first}, {rest}", + "@repeatYearlyMonthListStart": { + "description": "Prepends a localized month name to an already localized yearly recurrence month list.", + "placeholders": { + "first": {"type": "String"}, + "rest": {"type": "String"} + } + }, + "repeatYearlyOnMonthDaySummary": "{frequency} on {month} {day}", + "@repeatYearlyOnMonthDaySummary": { + "description": "Complete yearly recurrence summary for one day in one month.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "day": {"type": "String"} + } + }, + "repeatYearlyOnMonthDaysSummary": "{frequency} on days {days} of {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for multiple days in one month.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} on day {day} of {months}", + "@repeatYearlyInMonthsOnMonthDaySummary": { + "description": "Complete yearly recurrence summary for one day in multiple months.", + "placeholders": { + "frequency": {"type": "String"}, + "months": {"type": "String"}, + "day": {"type": "String"} + } + }, + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} on days {days} of {months}", + "@repeatYearlyInMonthsOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for multiple days in multiple months.", + "placeholders": { + "frequency": {"type": "String"}, + "months": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{on the first {days} of {month}} second{on the second {days} of {month}} third{on the third {days} of {month}} fourth{on the fourth {days} of {month}} fifth{on the fifth {days} of {month}} secondToLast{on the second to last {days} of {month}} last{on the last {days} of {month}} other{on {days} of {month}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{on the first {days} of {months}} second{on the second {days} of {months}} third{on the third {days} of {months}} fourth{on the fourth {days} of {months}} fifth{on the fifth {days} of {months}} secondToLast{on the second to last {days} of {months}} last{on the last {days} of {months}} other{on {days} of {months}}}", + "@repeatYearlyInMonthsOnOrdinalSummary": { + "description": "Complete yearly ordinal-weekday recurrence summary for multiple months.", + "placeholders": { + "frequency": {"type": "String"}, + "months": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index e2201aab..484579af 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,14 +1,31 @@ { "@@locale": "es", + "windowsSupport": "Soporte", + "windowsThirdPartyLicenses": "Licencias de terceros", + "windowsSearch": "Buscar", + "windowsStartupDisabledByUser": "Desactivado por el usuario en Configuración de Windows.", + "windowsStartupDisabledByPolicy": "Desactivado por una directiva de Windows.", + "windowsStartupUnavailable": "Disponible después de instalar BusyMax desde un paquete MSIX.", + "windowsReminderExitNotice": "Los recordatorios se detienen al cerrar BusyMax por completo. Déjalo ejecutándose en segundo plano para recibirlos.", + "windowsProductVersionLabel": "Versión del producto", + "windowsPackageVersionLabel": "Versión del paquete de Windows", + "windowsUnpackaged": "Sin empaquetar", + "windowsAgendaLoadMore": "Cargar más elementos de la agenda", + "repeatWeeklyDaySummary": "{dayKey, select, MO{lunes} TU{martes} WE{miércoles} TH{jueves} FR{viernes} SA{sábado} SU{domingo} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "los días {first} y {second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} los días {firstDay} y {secondDay} de {month}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} el día {day} de {firstMonth} y {secondMonth}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} los días {firstDay} y {secondDay} de {firstMonth} y {secondMonth}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} los días {days} de {firstMonth} y {secondMonth}", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Conecta cuentas de Google, Microsoft, Calendario de Apple iCloud o Nextcloud.", "googlePermissionsConsentNotice": "En la pantalla de permisos de Google, selecciona los permisos de Calendario y Tareas.", "googlePermissionsRequiredRetry": "Los permisos de Google Calendar y Google Tasks son obligatorios. Inténtalo de nuevo y selecciona ambas casillas.", "finishSetup": "Finalizar configuración", "continueSetup": "Continuar", "onboardingSetupTitle": "Configurar BusyMax", "onboardingAccountsStepTitle": "Conectar cuentas", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "Añade todas las cuentas que quieras usar. BusyMax sincroniza los calendarios, eventos, listas de tareas y tareas compatibles de cada cuenta.", "onboardingPreferencesStepTitle": "Elegir ajustes del sistema", "onboardingPreferencesStepDescription": "Configura el comportamiento de la aplicación en el escritorio, los recordatorios, el nivel de detalle de las notificaciones y la apariencia antes de abrir tu agenda.", "signInWithGoogle": "Iniciar sesión con Google", @@ -33,6 +50,43 @@ "calendarTasks": "Tareas", "calendar": "Calendario", "calendars": "Calendarios", + "newCalendar": "Nuevo calendario", + "calendarColor": "Color del calendario", + "calendarColorOption": "Opción de color {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "Este proveedor no admite la gestión de calendarios en BusyMax.", + "primaryCalendarCannotDelete": "El calendario principal no se puede eliminar.", + "calendarCreateFailed": "No se pudo crear el calendario: {error}", + "calendarCreatedRefreshPending": "El calendario se creó, pero BusyMax no pudo actualizar la cuenta. Aparecerá después de la próxima sincronización.", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "No se pudo actualizar el calendario: {error}", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "No se pudo eliminar el calendario: {error}", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "Nuevo evento", "refreshCalendar": "Actualizar calendario", "openInProvider": "Abrir en el proveedor", @@ -52,13 +106,62 @@ "scheduleNoSearchResultsDescription": "Prueba con otra búsqueda o borra los filtros actuales.", "refresh": "Actualizar", "trayOpenBusyMax": "Abrir BusyMax", + "trayShowBusyMax": "Mostrar BusyMax", + "trayNewEvent": "Evento nuevo…", + "trayNewTask": "Tarea nueva…", + "trayToday": "Hoy", + "trayAllDay": "Todo el día", + "trayNow": "Ahora", + "trayCalendarEvent": "Evento del calendario", + "trayUntitledEvent": "Evento sin título", + "trayNothingElseToday": "Nada más hoy", + "trayTasksDueToday": "{count, plural, =1{1 tarea vence hoy} other{{count} tareas vencen hoy}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Abrir la agenda de hoy", + "traySyncNow": "Sincronizar ahora", + "traySyncing": "Sincronizando…", + "trayNotConnected": "No conectado", + "trayNotYetSynced": "Aún no sincronizado", + "trayLastSyncedJustNow": "Sincronizado ahora mismo", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Sincronizado hace 1 minuto} other{Sincronizado hace {count} minutos}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Sincronizado hace 1 hora} other{Sincronizado hace {count} horas}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Sincronizado hace 1 día} other{Sincronizado hace {count} días}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Configuración", + "trayQuitBusyMax": "Salir de BusyMax", "agendaLoadMoreOverdue": "Cargar más tareas vencidas", "agendaLoadMoreNoDate": "Cargar más tareas sin fecha", "viewDay": "Día", "viewWeek": "Semana", "viewMonth": "Mes", "viewYear": "Año", - "viewAgenda": "Agenda", + "viewAgenda": "Vista de agenda", "scheduleSettings": "Agenda", "scheduleDisplaySettings": "Visualización de agenda", "scheduleDisplayHoursDescription": "Las vistas de día y semana muestran inicialmente este intervalo horario. Los elementos anteriores o posteriores lo amplían cuando es necesario.", @@ -78,7 +181,7 @@ "doesNotRepeat": "No se repite", "defaultReminder": "Recordatorio predeterminado", "guests": "Invitados", - "noGuests": "Sin invitados", + "noGuests": "No hay invitados", "description": "Descripción", "availabilityShowAs": "Disponibilidad / Mostrar como", "busy": "Ocupado", @@ -135,7 +238,7 @@ "navigation": "Navegación", "mainMenu": "Menú principal", "keyboardShortcuts": "Atajos de teclado", - "shortcutGroupGeneral": "General", + "shortcutGroupGeneral": "Atajos generales", "shortcutKeyboardShortcutsDescription": "Mostrar esta referencia de atajos", "shortcutGroupNavigation": "Navegación", "shortcutNextPeriod": "Periodo siguiente", @@ -175,7 +278,7 @@ "feedbackDetailedMessage": "Mensaje detallado", "feedbackReplyEmail": "Correo electrónico para recibir una respuesta (opcional)", "feedbackIncludeTechnicalDetails": "Incluir detalles técnicos", - "feedbackTechnicalDetailsDisclosure": "Incluye únicamente la versión del sistema operativo Linux y la configuración regional de la aplicación. No se incluyen registros, datos de cuenta, nombres de archivos ni otros diagnósticos.", + "feedbackTechnicalDetailsDisclosure": "Incluye únicamente el nombre y la versión del sistema operativo y la configuración regional de la aplicación. No se incluyen registros, datos de cuenta, nombres de archivos ni otros diagnósticos.", "feedbackCategoryRequired": "Selecciona una categoría.", "feedbackSubjectLengthError": "El asunto debe tener entre 3 y 120 caracteres.", "feedbackMessageLengthError": "El mensaje debe tener entre 10 y 5.000 caracteres.", @@ -200,7 +303,7 @@ "removeAccount": "Eliminar cuenta…", "removingAccount": "Eliminando cuenta…", "removeAccountDescription": "Detener la sincronización y eliminar de este dispositivo los datos de esta cuenta.", - "removeAccountTitle": "¿Eliminar {account} de BusyMax?", + "removeAccountTitle": "¿Quitar {account} de BusyMax?", "@removeAccountTitle": { "placeholders": { "account": { @@ -208,13 +311,13 @@ } } }, - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "Esto elimina de este dispositivo las tareas, calendarios, eventos, recordatorios y cambios sin conexión pendientes almacenados en caché. Se perderán los cambios no sincronizados. No se eliminan las copias de calendarios, eventos, listas de tareas ni tareas del proveedor.", "revokeGoogleAccess": "Revocar también el acceso de BusyMax a esta cuenta de Google", "revokeGoogleAccessDescription": "Tendrás que volver a conceder acceso antes de reconectar la cuenta.", "removeAccountAction": "Eliminar cuenta", "removeAccountFailed": "No se pudo terminar de eliminar la cuenta. Inténtalo de nuevo.", "accountRemovedGoogleRevokeFailed": "La cuenta se eliminó de este dispositivo, pero BusyMax no pudo revocar su acceso a tu cuenta de Google. Puedes revocarlo desde tu cuenta de Google.", - "newTaskList": "Nueva lista de tareas", + "newTaskList": "Lista de tareas nueva", "signInToViewTaskLists": "Inicia sesión para ver las listas de tareas.", "noTaskListsSynced": "Aún no hay listas de tareas sincronizadas.", "listActions": "Acciones de lista", @@ -224,7 +327,7 @@ "deleteList": "Eliminar lista", "builtInMicrosoftList": "Integrada", "builtInMicrosoftListCannotRenameDelete": "Las listas integradas de Microsoft To Do no se pueden cambiar de nombre ni eliminar.", - "deleteListConfirmation": "¿Eliminar \"{title}\" de Google Tasks?", + "deleteListConfirmation": "¿Eliminar «{title}» de Google Tasks?", "deleteEvent": "Eliminar evento", "title": "Título", "create": "Crear", @@ -275,7 +378,7 @@ "primarySection": "Principal", "statusSection": "Estado", "openStatus": "Abierta", - "doneStatus": "Completada", + "doneStatus": "Hecha", "notes": "Notas", "dueDate": "Fecha de vencimiento", "clearDueDate": "Borrar fecha de vencimiento", @@ -301,7 +404,7 @@ "repeatYearly": "Anual", "importance": "Importancia", "importanceLow": "Baja", - "importanceNormal": "Normal", + "importanceNormal": "Media", "importanceHigh": "Alta", "categories": "Categorías", "scheduleSection": "Programación", @@ -333,7 +436,8 @@ "synced": "Sincronizada", "account": "Cuenta", "sync": "Sincronización", - "manualFullSync": "Sincronización completa manual", + "forceFullResync": "Forzar una resincronización completa", + "forceFullResyncDescription": "Vuelve a cargar por completo los datos de todas las cuentas conectadas. Esta opción solo debe usarse para solucionar problemas de sincronización.", "runInBackgroundWhenClosed": "Seguir ejecutándose al cerrar la ventana", "showTrayIcon": "Mostrar el icono en la bandeja del sistema", "startMinimizedToTray": "Iniciar minimizado en la bandeja del sistema", @@ -347,18 +451,23 @@ "notifyConflicts": "Notificaciones de conflictos", "notifyDueToday": "Notificaciones de tareas que vencen hoy", "eventReminders": "Recordatorios de eventos", + "onState": "Activado", "taskReminders": "Recordatorios de tareas", "notificationDetailLevel": "Nivel de detalle de las notificaciones", "notificationDetailPrivate": "Privado", - "notificationDetailNormal": "Normal", + "notificationDetailNormal": "Estándar", "quietHours": "Horario de silencio", "quietHoursDescription": "Pausar las notificaciones durante este período.", "quietHoursStart": "Inicio del horario de silencio", "quietHoursEnd": "Fin del horario de silencio", "notifications": "Notificaciones", + "windowsNotificationsUnavailable": "Las notificaciones de Windows no están disponibles", + "windowsNotificationsUnpackaged": "Esta ejecución de desarrollo sin empaquetar no puede usar las notificaciones de Windows. Instala el MSIX firmado para pruebas para comprobar los recordatorios.", + "windowsNotificationsInstalledFailure": "BusyMax no pudo inicializar las notificaciones de Windows. Los recordatorios no aparecerán hasta que se resuelva este problema de instalación.", "appearance": "Apariencia", "theme": "Tema", "themeSystem": "Sistema", + "settingsSystem": "Sistema", "themeLight": "Claro", "themeDark": "Oscuro", "themeFamily": "Familia de temas", @@ -394,7 +503,7 @@ "syncFailureNotificationTitle": "Falló la sincronización de BusyMax", "syncFailureNotificationBody": "Falló la sincronización en segundo plano. {message}", "conflictNotificationTitle": "Conflicto de sincronización de BusyMax", - "conflictNotificationBody": "Se bloqueó un cambio local pendiente. {summary}", + "conflictNotificationBody": "Se ha bloqueado un cambio local pendiente. {summary}", "dueTodayNotificationTitle": "Tareas que vencen hoy", "dueTodayNotificationBody": "{count, plural, =1{Una tarea vence hoy.} other{{count} tareas vencen hoy.}}", "eventReminderNotificationTitle": "Recordatorio de evento", @@ -403,7 +512,7 @@ "taskReminderNotificationBody": "La tarea vence pronto.", "notificationOpenAction": "Abrir", "notificationSnoozeAction": "Posponer 10 minutos", - "notificationDismissAction": "Descartar", + "notificationDismissAction": "Cerrar", "notificationDetailsHidden": "Los detalles están ocultos por la configuración de privacidad.", "previousMonth": "Mes anterior", "nextMonth": "Mes siguiente", @@ -431,45 +540,45 @@ "readOnlyCalendar": "Este calendario es de solo lectura.", "selectTimeZone": "Seleccionar zona horaria", "searchLocations": "Buscar ubicaciones", - "noLocationsFound": "No se encontraron ubicaciones", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "noLocationsFound": "No se han encontrado ubicaciones", + "requiredField": "Este campo es obligatorio.", + "providerConnectionDescription": "Conecta calendarios y tareas de uno de estos proveedores.", + "appleICloudProvider": "Calendario de Apple iCloud", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Tareas de Nextcloud", + "addAppleICloudAccount": "Añadir cuenta del Calendario de Apple iCloud", + "addNextcloudAccount": "Añadir cuenta de Nextcloud", + "waitingForAppleICloud": "Conectando con Apple iCloud…", + "waitingForNextcloud": "Esperando la autorización de Nextcloud…", + "connectAppleICloudTitle": "Conectar el Calendario de Apple iCloud", + "appleAccountEmail": "Correo de la cuenta de Apple", + "appleAppSpecificPassword": "Contraseña específica de la app", + "appleAppSpecificPasswordHelp": "Crea una contraseña específica de la app después de activar la autenticación de dos factores en tu cuenta de Apple.", + "appleAppSpecificPasswordResetWarning": "Restablecer la contraseña de tu cuenta de Apple revoca las contraseñas específicas de las apps.", + "connectNextcloudTitle": "Conectar Nextcloud", + "nextcloudServerUrl": "Servidor de Nextcloud o dirección CalDAV", + "nextcloudServerUrlHelp": "Introduce la URL de tu servidor Nextcloud o pega la dirección CalDAV principal copiada de Nextcloud.", + "nextcloudBrowserAuthorizationHelp": "BusyMax abrirá el navegador. Aprueba allí el acceso y vuelve a BusyMax.", + "connectAccountAction": "Conectar", + "cancelAccountConnection": "Cancelar conexión", + "nextcloudAccountRemovedRevokeFailed": "La cuenta se quitó localmente, pero no se pudo revocar la contraseña de app de Nextcloud.", + "davCachedOfflineNotice": "Los datos del calendario y las tareas se almacenan localmente para usarlos sin conexión.", + "davReauthenticationRequired": "Vuelve a conectar esta cuenta para reanudar la sincronización.", + "davTemporarilyUnavailable": "Esta cuenta no está disponible temporalmente.", + "davPermissionChanged": "Han cambiado los permisos del servidor. Las ediciones pendientes están en pausa.", + "davUnsupportedServer": "Este servidor o perfil de proveedor no es compatible.", + "collectionSettings": "Calendarios y listas de tareas", + "calendarContent": "Eventos del calendario", + "taskContent": "Tareas", + "readOnlySharedCollection": "Solo lectura", + "pendingLocally": "Pendiente localmente", + "conflictBlocked": "Bloqueado por un conflicto", + "authenticationBlocked": "Bloqueado hasta volver a conectar", + "operationFailed": "La operación ha fallado", + "keepServerVersion": "Conservar la versión del servidor", + "reapplyLocalChange": "Revisar y volver a aplicar el cambio local", + "duplicateLocalItem": "Duplicar como elemento nuevo", "deleteCalendarConfirmation": "¿Eliminar \"{title}\"?", "@deleteCalendarConfirmation": { "placeholders": { @@ -478,11 +587,11 @@ } } }, - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "Estado de conexión", + "davConnected": "Conectado", + "davConnecting": "Conectando…", + "davSignedOut": "Sesión cerrada", + "davLastSuccessfulSync": "Última sincronización correcta: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -490,9 +599,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "Aún no se ha sincronizado", + "refreshCollections": "Actualizar calendarios y listas de tareas", + "nextcloudServerHost": "Servidor: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -500,12 +609,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "Calendario de eventos", + "collectionSupportsTasks": "Lista de tareas", + "collectionSupportsEventsAndTasks": "Eventos y tareas", + "writableCollection": "Editable", + "sharedCollection": "Compartido", + "collectionLastSynced": "Última sincronización: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -513,7 +622,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "Problema de sincronización: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -521,8 +630,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "Conflictos de sincronización", + "remoteChangedAt": "Cambio en el servidor: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -530,7 +639,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "Edición local: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -538,144 +647,366 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "conflictResolutionFailed": "No se pudo resolver el conflicto.", + "recurringEventScope": "Ámbito del evento recurrente", + "entireSeries": "Serie completa", + "singleOccurrence": "Este evento", + "thisAndFollowingEvents": "Este evento y los siguientes", + "thisAndFutureUnavailable": "No compatible con este proveedor.", + "thisAndFutureMoveUnavailable": "No se pueden mover de forma segura este evento y los siguientes. Elige este evento o toda la serie.", + "entireSeriesMoveUnavailable": "La regla de repetición no está disponible localmente. Mueve solo este evento.", + "copyEventAndDeleteOriginal": "¿Copiar el evento y eliminar el original?", + "copyEventMoveWarning": "BusyMax no puede mover este evento directamente de {source} a {destination}. Primero creará la copia y eliminará el original solo cuando la copia se complete correctamente. Los identificadores del evento cambiarán; los estados de respuesta de los asistentes pueden restablecerse y pueden enviarse invitaciones o cancelaciones; y es posible que no se conserven los enlaces de conferencia, los archivos adjuntos, los recordatorios, los campos específicos del proveedor ni las excepciones de repetición.", + "copyAndDelete": "Copiar y eliminar", + "chooseRecurringEventScope": "Elige si este cambio se aplica a toda la serie, solo a este evento o a este y los siguientes eventos.", "taskDueBeforeStart": "El vencimiento no puede ser anterior al inicio.", "taskStartDueTimeModeMismatch": "Define una hora tanto para el inicio como para el vencimiento, o configura la tarea para todo el día.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", - "unshare": "No compartir", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "taskListCreateFailed": "No se pudo crear la lista de tareas: {error}", + "taskListRenameFailed": "No se pudo cambiar el nombre de la lista de tareas: {error}", + "taskListDeleteFailed": "No se pudo eliminar la lista de tareas: {error}", + "unshare": "Dejar de compartir", + "readOnlyTaskListCannotRename": "Esta lista de tareas es de solo lectura y no se puede cambiar su nombre.", + "taskListCannotDelete": "No se puede eliminar esta lista de tareas con tus permisos actuales.", + "deleteTaskListConfirmation": "¿Eliminar «{title}» y todas sus tareas?", + "unshareTaskListConfirmation": "¿Dejar de compartir «{title}» con esta cuenta?", + "taskStatus": "Estado", + "taskStatusNone": "Sin estado", "taskStatusNeedsAction": "Necesita una acción", - "taskStatusInProcess": "En proceso", + "taskStatusInProcess": "En curso", "taskStatusCompleted": "Completada", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusCancelled": "Cancelada", + "completionPercent": "{percent}% completada", + "completionDate": "Fecha de finalización", "priority": "Prioridad", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "Sin recordatorio", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Antes de empezar la tarea", - "beforeTaskDue": "Antes de terminar la tarea", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", + "priorityNone": "Sin prioridad", + "priorityHighValue": "Prioridad {priority} · Alta", + "priorityMediumValue": "Prioridad {priority} · Media", + "priorityLowValue": "Prioridad {priority} · Baja", + "taskUrl": "URL de la tarea", + "invalidTaskUrl": "Introduce una URL absoluta, incluido su esquema.", + "classification": "Clasificación", + "classificationPublic": "Al compartirla, mostrar la tarea completa", + "classificationConfidential": "Al compartirla, mostrar solo la disponibilidad", + "classificationPrivate": "Al compartirla, ocultar esta tarea", + "pinTask": "Fijar tarea", + "reminders": "Recordatorios", + "noReminders": "Sin recordatorios", + "editReminder": "Editar recordatorio", + "beforeTaskStarts": "Antes de que empiece la tarea", + "beforeTaskDue": "Antes del vencimiento de la tarea", + "afterTaskStarts": "Después de que empiece la tarea", + "afterTaskDue": "Después del vencimiento de la tarea", + "relativeToTaskStart": "En relación con la fecha de inicio de la tarea", + "relativeToTaskDue": "En relación con la fecha de vencimiento de la tarea", + "reminderTimeOfDay": "Hora del día", + "absoluteReminder": "En una fecha y hora", + "reminderAmount": "Cantidad", + "reminderUnitSeconds": "Segundos", + "reminderUnitMinutes": "Minutos", + "reminderUnitHours": "Horas", + "reminderUnitDays": "Días", + "reminderUnitWeeks": "Semanas", + "reminderAtTaskStart": "Al comenzar la tarea", + "reminderAtTaskDue": "Al vencer la tarea", + "unsupportedReminder": "Este tipo de recordatorio se conserva, pero no se puede editar su hora.", + "relatedRemindersTitle": "¿Conservar los recordatorios relacionados?", + "relatedRemindersDescription": "Esta fecha tiene {count} recordatorios relacionados. ¿Quieres conservarlos en su fecha y hora actuales?", "discardRelatedReminders": "Descartar recordatorios", - "keepRelatedReminders": "Mantener recordatorios", - "repeatEvery": "Repetir cada", - "repeatOn": "Repeat on", + "keepRelatedReminders": "Conservar recordatorios", + "repeatEvery": "Intervalo", + "repeatOn": "Repetir en", "repeatEnd": "Finalizar repetición", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "dentro de {days}", - "repeatOnMonthDaysSummary": "en día {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", + "repeatNever": "Nunca", + "repeatUntil": "En una fecha", + "repeatAfter": "Después de un número de repeticiones", + "repeatCount": "Repeticiones", + "repeatDayOfMonth": "Días del mes", + "repeatMonths": "Meses", + "repeatOrdinal": "Posición del día de la semana", + "repeatSpecificDays": "Días específicos", + "repeatFirst": "Primero", + "repeatSecond": "Segundo", + "repeatThird": "Tercero", + "repeatFourth": "Cuarto", + "repeatFifth": "Quinto", + "repeatSecondToLast": "Penúltimo", + "repeatLast": "Último", + "repeatAnyDay": "Día", + "repeatWeekday": "Día laborable", + "repeatWeekendDay": "Día de fin de semana", + "repeatOrdinalDaySummary": "{dayKey, select, MO{lunes} TU{martes} WE{miércoles} TH{jueves} FR{viernes} SA{sábado} SU{domingo} day{día} weekday{día laborable} weekend{día de fin de semana} other{{day}}}", + "repeatEveryDays": "Cada {count} días", + "repeatEveryWeeks": "Cada {count} semanas", + "repeatEveryMonths": "Cada {count} meses", + "repeatEveryYears": "Cada {count} años", + "repeatOnDaysSummary": "los {days}", + "repeatOnMonthDaysSummary": "el día {days}", + "repeatOnOrdinalSummary": "{position, select, first{el primer {days}} second{el segundo {days}} third{el tercer {days}} fourth{el cuarto {days}} fifth{el quinto {days}} secondToLast{el penúltimo {days}} last{el último {days}} other{en {days}}}", "repeatInMonthsSummary": "en {months}", - "repeatTimesSummary": "{count} veces", - "repeatUntilSummary": "hasta el {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "repeatTimesSummary": "{count, plural, one{{count} vez} other{{count} veces}}", + "repeatUntilSummary": "hasta {date}", + "unsupportedRecurrencePreserved": "Esta regla de repetición usa opciones que este editor no modifica.", + "recurrenceUnsupportedByProvider": "Esta repetición no se puede usar con {provider}.", "duplicateTask": "Duplicar tarea", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "taskDuplicated": "Tarea duplicada.", + "taskDuplicateFailed": "No se pudo duplicar la tarea: {error}", "hideSubtasks": "Ocultar subtareas", "hideClosedSubtasks": "Ocultar subtareas cerradas", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "reminderUnit": "Unidad", + "attendeeRequired": "Obligatorio", + "attendeeOptional": "Opcional", + "meetingSection": "Reunión", + "addGoogleMeet": "Añadir Google Meet", + "addTeamsMeeting": "Añadir reunión de Microsoft Teams", + "onlineMeetingAdded": "Reunión en línea añadida", + "requestResponses": "Solicitar respuestas", + "requestResponsesDescription": "Pide a los invitados que respondan a la invitación.", + "hideGuestList": "Ocultar la lista de invitados", + "hideGuestListDescription": "Los invitados no pueden ver quién más ha sido invitado.", + "allowNewTimeProposals": "Permitir nuevas propuestas de horario", + "allowNewTimeProposalsDescription": "Los invitados pueden sugerir otra hora para la reunión.", + "notifyGuestsTitle": "¿Notificar a los invitados?", + "notifyGuestsSaveMessage": "Esta reunión tiene invitados. ¿Enviar invitaciones o actualizaciones del evento al guardarla?", + "notifyGuestsDeleteMessage": "Esta reunión tiene invitados. ¿Enviar una cancelación al eliminarla?", + "sendUpdates": "Enviar actualizaciones", + "sendCancellation": "Enviar cancelación", + "doNotSend": "No enviar", + "microsoftNotifyGuestsSaveTitle": "¿Guardar la reunión?", + "microsoftNotifyGuestsSaveMessage": "Microsoft enviará invitaciones o actualizaciones del evento a los invitados.", + "microsoftNotifyGuestsDeleteTitle": "¿Eliminar la reunión?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft enviará una cancelación a los invitados.", + "organizer": "Organizador", + "yourResponse": "Tu respuesta", + "guestResponses": "Respuestas de los invitados", + "respond": "Responder", + "acceptInvitation": "Aceptar", + "tentativeInvitation": "Provisional", + "declineInvitation": "Rechazar", + "joinMeeting": "Unirse a la reunión", + "responseAccepted": "Aceptada", + "responseTentative": "Provisional", + "responseDeclined": "Rechazada", + "responseNeedsAction": "Esperando respuesta", + "responseNotResponded": "Sin respuesta", + "responseOrganizer": "Organizador", + "invitationResponseFailed": "No se pudo enviar tu respuesta: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "No se pudo abrir el enlace de la reunión.", + "setCustomCalendarName": "Establecer nombre personalizado", + "setAction": "Establecer", + "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.", + "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.", + "calendarSubscriptions": "Suscripciones de calendarios", + "calendarSubscriptionsDescription": "Añade calendarios de solo lectura que se actualicen desde una URL WebCal segura.", + "addCalendarSubscription": "Añadir suscripción de calendario", + "subscriptionName": "Nombre local", + "subscriptionUrl": "URL de la suscripción", + "subscriptionUrlHelp": "Introduce una URL HTTPS o webcal. BusyMax guarda la URL completa en un almacenamiento seguro.", + "subscriptionUrlInvalid": "Introduce una URL HTTPS o webcal válida sin información de usuario ni fragmento.", + "subscriptionColor": "Color local", + "subscriptionColorHelp": "Usa un color de seis dígitos, como #3584E4.", + "subscriptionColorInvalid": "Introduce un color hexadecimal de seis dígitos.", + "subscriptionRefreshMode": "Frecuencia de actualización", + "subscriptionAutomatic": "Automática", + "subscriptionHourly": "Cada hora", + "subscriptionSixHours": "Cada seis horas", + "subscriptionDaily": "Diaria", + "subscriptionSafeOrigin": "Origen: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Introduce una URL válida para previsualizar su origen seguro.", + "subscriptionReadOnly": "Suscripción de solo lectura", + "subscriptionNeverRefreshed": "Aún no se ha actualizado", + "subscriptionLastRefresh": "Última actualización correcta: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Próxima actualización: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Actualizada", + "subscriptionStatusIssue": "Problema de actualización: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Actualizar ahora", + "unsubscribe": "Cancelar suscripción", + "unsubscribeCalendarTitle": "¿Cancelar la suscripción a «{name}»?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "Esto elimina la suscripción local y sus eventos almacenados en caché. El calendario publicado no cambia.", + "addSubscriptionAction": "Añadir suscripción", + "subscriptionOperationFailed": "La suscripción al calendario ha fallado: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Suscripciones", + "calendarImport": "Importación de calendario", + "calendarImportDescription": "Selecciona un archivo, revisa sus eventos y elige el calendario editable que debe recibirlos.", + "importIcsFile": "Importar archivo .ics", + "importIcsPreview": "Importar eventos del calendario", + "importEventsFound": "Conjuntos de eventos importables: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Eventos no válidos: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Omitidos intencionadamente: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "No hay ningún calendario de destino editable disponible.", + "importDestinationCalendar": "Calendario de destino", + "importIcsConfirm": "Importar eventos", + "importIcsComplete": "Importación completada", + "importQueued": "Importados o en cola: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Duplicados omitidos: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Conjuntos de repeticiones no compatibles: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "No se pudo importar el archivo de calendario: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "Sin conexión", "networkOfflineDescription": "Los cambios se sincronizarán cuando se restablezca la conexión.", - "networkOfflineTryAgain": "No tienes conexión. Conéctate a Internet e inténtalo de nuevo." + "networkOfflineTryAgain": "No tienes conexión. Conéctate a Internet e inténtalo de nuevo.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "los días {days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first} y {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} y {second}", + "repeatMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} y {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} y {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} el día {day} de {month}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} el día {day} de {months}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} los días {days} de {months}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{el primer {days} de {months}} second{el segundo {days} de {months}} third{el tercer {days} de {months}} fourth{el cuarto {days} de {months}} fifth{el quinto {days} de {months}} secondToLast{el penúltimo {days} de {months}} last{el último {days} de {months}} other{en {days} de {months}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency} los días {days} de {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{el primer {days} de {month}} second{el segundo {days} de {month}} third{el tercer {days} de {month}} fourth{el cuarto {days} de {month}} fifth{el quinto {days} de {month}} secondToLast{el penúltimo {days} de {month}} last{el último {days} de {month}} other{en {days} de {month}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 8843b672..f97cc89f 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1,22 +1,39 @@ { "@@locale": "et", + "windowsSupport": "Tugi", + "windowsThirdPartyLicenses": "Kolmandate osapoolte litsentsid", + "windowsSearch": "Otsi", + "windowsStartupDisabledByUser": "Kasutaja keelas selle Windowsi sätetes.", + "windowsStartupDisabledByPolicy": "Windowsi poliitika on selle keelanud.", + "windowsStartupUnavailable": "Saadaval pärast BusyMaxi installimist MSIX-paketist.", + "windowsReminderExitNotice": "BusyMaxi täielikul sulgemisel meeldetuletused peatuvad. Nende saamiseks jätke rakendus taustal tööle.", + "windowsProductVersionLabel": "Toote versioon", + "windowsPackageVersionLabel": "Windowsi paketi versioon", + "windowsUnpackaged": "Pakkimata", + "windowsAgendaLoadMore": "Laadi rohkem päevakava üksusi", + "repeatWeeklyDaySummary": "{dayKey, select, MO{esmaspäeviti} TU{teisipäeviti} WE{kolmapäeviti} TH{neljapäeviti} FR{reedeti} SA{laupäeviti} SU{pühapäeviti} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "{first} ja {second} päeval", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} {month} {firstDay} ja {secondDay} päeval", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} {firstMonth} ja {secondMonth} {day} päeval", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} {firstMonth} ja {secondMonth} {firstDay} ja {secondDay} päeval", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} {firstMonth} ja {secondMonth} {days} päeval", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", - "googlePermissionsConsentNotice": "Valige Google'i õiguste kuval nii kalendri kui ka ülesannete õigused.", + "connectGoogleAccount": "Ühendage Google’i, Microsofti, Apple iCloud Calendari või Nextcloudi kontod.", + "googlePermissionsConsentNotice": "Valige Google’i õiguste kuval nii kalendri kui ka ülesannete õigused.", "googlePermissionsRequiredRetry": "Google Calendari ja Google Tasksi õigused on nõutavad. Proovige uuesti ja märkige mõlemad ruudud.", "finishSetup": "Lõpeta seadistamine", "continueSetup": "Jätka", "onboardingSetupTitle": "BusyMaxi seadistamine", "onboardingAccountsStepTitle": "Kontode ühendamine", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "Lisage kõik kontod, mida soovite kasutada. BusyMax sünkroonib igast kontost toetatud kalendreid, sündmusi, ülesandeloendeid ja ülesandeid.", "onboardingPreferencesStepTitle": "Süsteemiseadete valimine", "onboardingPreferencesStepDescription": "Enne ajakava avamist määrake töölauakäitumine, meeldetuletused, teavituste üksikasjalikkus ja välimus.", - "signInWithGoogle": "Logi Google'iga sisse", + "signInWithGoogle": "Logi Google’iga sisse", "signInWithMicrosoft": "Logi Microsoftiga sisse", "googleTasksProvider": "Google Tasks", "microsoftTodoProvider": "Microsoft To Do", "providerNotConfigured": "See teenusepakkuja pole seadistatud.", - "waitingForGoogleSignIn": "Google'isse sisselogimise ootel...", + "waitingForGoogleSignIn": "Google’isse sisselogimise ootel...", "waitingForMicrosoftSignIn": "Microsofti sisselogimise ootel...", "microsoftSignInNotConfigured": "Microsofti sisselogimine pole seadistatud. Määrake MICROSOFT_OAUTH_CLIENT_ID.", "cancel": "Tühista", @@ -33,6 +50,43 @@ "calendarTasks": "Ülesanded", "calendar": "Kalender", "calendars": "Kalendrid", + "newCalendar": "Uus kalender", + "calendarColor": "Kalendri värv", + "calendarColorOption": "Värv {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "See teenusepakkuja ei toeta BusyMaxis kalendrite haldamist.", + "primaryCalendarCannotDelete": "Põhikalendrit ei saa kustutada.", + "calendarCreateFailed": "Kalendri loomine nurjus: {error}", + "calendarCreatedRefreshPending": "Kalender loodi, kuid BusyMax ei saanud kontot värskendada. See ilmub pärast järgmist sünkroonimist.", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "Kalendri värskendamine nurjus: {error}", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "Kalendri kustutamine nurjus: {error}", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "Uus sündmus", "refreshCalendar": "Värskenda kalendrit", "openInProvider": "Ava teenuses", @@ -59,6 +113,55 @@ "scheduleNoSearchResultsDescription": "Proovige teistsugust otsingut või eemaldage praegused filtrid.", "refresh": "Värskenda", "trayOpenBusyMax": "Ava BusyMax", + "trayShowBusyMax": "Näita BusyMaxi", + "trayNewEvent": "Uus sündmus…", + "trayNewTask": "Uus ülesanne…", + "trayToday": "Täna", + "trayAllDay": "Kogu päev", + "trayNow": "Praegu", + "trayCalendarEvent": "Kalendrisündmus", + "trayUntitledEvent": "Pealkirjata sündmus", + "trayNothingElseToday": "Täna pole rohkem midagi", + "trayTasksDueToday": "{count, plural, =1{Täna on tähtajaks 1 ülesanne} other{Täna on tähtajaks {count} ülesannet}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Ava tänane päevakava", + "traySyncNow": "Sünkrooni kohe", + "traySyncing": "Sünkroonimine…", + "trayNotConnected": "Pole ühendatud", + "trayNotYetSynced": "Veel sünkroonimata", + "trayLastSyncedJustNow": "Viimati sünkrooniti just nüüd", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Viimati sünkrooniti 1 minut tagasi} other{Viimati sünkrooniti {count} minutit tagasi}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Viimati sünkrooniti 1 tund tagasi} other{Viimati sünkrooniti {count} tundi tagasi}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Viimati sünkrooniti 1 päev tagasi} other{Viimati sünkrooniti {count} päeva tagasi}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Seaded", + "trayQuitBusyMax": "Välju BusyMaxist", "agendaLoadMoreOverdue": "Laadi veel tähtaja ületanud ülesandeid", "agendaLoadMoreNoDate": "Laadi veel kuupäevata ülesandeid", "viewDay": "Päev", @@ -189,7 +292,7 @@ "feedbackDetailedMessage": "Üksikasjalik sõnum", "feedbackReplyEmail": "Vastamise e-posti aadress (valikuline)", "feedbackIncludeTechnicalDetails": "Lisa tehnilised üksikasjad", - "feedbackTechnicalDetailsDisclosure": "Lisatakse ainult teie Linuxi operatsioonisüsteemi versioon ning rakenduse keel ja piirkonnaseaded. Logisid, kontoandmeid, failinimesid ega muid diagnostikaandmeid ei lisata.", + "feedbackTechnicalDetailsDisclosure": "Lisatakse ainult teie operatsioonisüsteemi nimi ja versioon ning rakenduse keel ja piirkonnaseaded. Logisid, kontoandmeid, failinimesid ega muid diagnostikaandmeid ei lisata.", "feedbackCategoryRequired": "Valige kategooria.", "feedbackSubjectLengthError": "Teema peab olema 3–120 tähemärki pikk.", "feedbackMessageLengthError": "Sõnum peab olema 10–5000 tähemärki pikk.", @@ -213,7 +316,7 @@ "accounts": "Kontod", "currentAccount": "Praegune konto", "switchAccount": "Vaheta kontot", - "addGoogleAccount": "Lisa Google'i konto", + "addGoogleAccount": "Lisa Google’i konto", "addMicrosoftAccount": "Lisa Microsofti konto", "googleProvider": "Google", "microsoftProvider": "Microsoft", @@ -229,12 +332,12 @@ } } }, - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", - "revokeGoogleAccess": "Tühista ka BusyMaxi juurdepääs sellele Google'i kontole", + "removeAccountConfirmation": "See kustutab sellest seadmest vahemällu salvestatud ülesanded, kalendrid, sündmused, meeldetuletused ja ootel võrguühenduseta muudatused. Sünkroonimata muudatused lähevad kaotsi. Teenusepakkuja kalendrite, sündmuste, ülesandeloendite ja ülesannete koopiaid ei kustutata.", + "revokeGoogleAccess": "Tühista ka BusyMaxi juurdepääs sellele Google’i kontole", "revokeGoogleAccessDescription": "Enne uuesti ühendamist peate juurdepääsu uuesti andma.", "removeAccountAction": "Eemalda konto", "removeAccountFailed": "Konto eemaldamist ei saanud lõpetada. Proovige uuesti.", - "accountRemovedGoogleRevokeFailed": "Konto eemaldati sellest seadmest, kuid BusyMaxi juurdepääsu teie Google'i kontole ei saanud tühistada. Saate selle oma Google'i kontol käsitsi tühistada.", + "accountRemovedGoogleRevokeFailed": "Konto eemaldati sellest seadmest, kuid BusyMaxi juurdepääsu teie Google’i kontole ei saanud tühistada. Saate selle oma Google’i kontol käsitsi tühistada.", "newTaskList": "Uus ülesandeloend", "signInToViewTaskLists": "Ülesandeloendite vaatamiseks logige sisse.", "noTaskListsSynced": "Ühtegi ülesandeloendit pole veel sünkroonitud.", @@ -327,7 +430,7 @@ "primarySection": "Põhiteave", "statusSection": "Olek", "openStatus": "Pooleli", - "doneStatus": "Valmis", + "doneStatus": "Tehtud", "notes": "Märkmed", "dueDate": "Tähtaeg", "clearDueDate": "Eemalda tähtaeg", @@ -392,12 +495,13 @@ "synced": "Sünkroonitud", "account": "Konto", "sync": "Sünkroonimine", - "manualFullSync": "Käsitsi täielik sünkroonimine", + "forceFullResync": "Sunni täielik uuesti sünkroonimine", + "forceFullResyncDescription": "Laadib kõigi ühendatud kontode andmed täielikult uuesti. Kasutage seda ainult sünkroonimisprobleemide tõrkeotsinguks.", "runInBackgroundWhenClosed": "Jätka töötamist, kui aken on suletud", "showTrayIcon": "Kuva süsteemisalve ikoon", "startMinimizedToTray": "Käivita minimeerituna süsteemisalves", "launchAtLogin": "Käivita sisselogimisel", - "launchAtLoginDescription": "Käivita BusyMax taustal, et meeldetuletused töötaksid pärast sisselogimist.", + "launchAtLoginDescription": "BusyMax käivitatakse taustal, et meeldetuletused töötaksid pärast sisselogimist.", "launchAtLoginFailed": "Sisselogimisel käivitamise seadet ei saanud uuendada.", "requiresTrayIcon": "Nõuab süsteemisalve ikooni.", "syncComplete": "Sünkroonimine on lõpetatud.", @@ -413,6 +517,7 @@ "notifyConflicts": "Teavitused konfliktide korral", "notifyDueToday": "Täna tähtuvate ülesannete teavitused", "eventReminders": "Sündmuste meeldetuletused", + "onState": "Sees", "taskReminders": "Ülesannete meeldetuletused", "notificationDetailLevel": "Teavituste üksikasjalikkus", "notificationDetailPrivate": "Privaatne", @@ -422,9 +527,13 @@ "quietHoursStart": "Vaikse aja algus", "quietHoursEnd": "Vaikse aja lõpp", "notifications": "Teavitused", + "windowsNotificationsUnavailable": "Windowsi teavitused pole saadaval", + "windowsNotificationsUnpackaged": "See pakendamata arendusversioon ei saa Windowsi teavitusi kasutada. Meeldetuletuste testimiseks paigalda testallkirjastatud MSIX.", + "windowsNotificationsInstalledFailure": "BusyMax ei saanud Windowsi teavitusi lähtestada. Meeldetuletusi ei kuvata enne, kui see paigaldusprobleem on lahendatud.", "appearance": "Välimus", "theme": "Kujundus", "themeSystem": "Süsteem", + "settingsSystem": "Süsteem", "themeLight": "Hele", "themeDark": "Tume", "themeFamily": "Kujunduse perekond", @@ -518,7 +627,7 @@ "taskReminderNotificationBody": "Ülesande tähtaeg on varsti.", "notificationOpenAction": "Ava", "notificationSnoozeAction": "Lükka 10 minutit edasi", - "notificationDismissAction": "Loobu", + "notificationDismissAction": "Sulge", "notificationDetailsHidden": "Üksikasjad on privaatsusseadete tõttu peidetud.", "previousMonth": "Eelmine kuu", "nextMonth": "Järgmine kuu", @@ -547,44 +656,44 @@ "selectTimeZone": "Valige ajavöönd", "searchLocations": "Otsi asukohti", "noLocationsFound": "Asukohti ei leitud", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "See väli on kohustuslik.", + "providerConnectionDescription": "Ühendage kalendrid ja ülesanded ühega neist teenusepakkujatest.", + "appleICloudProvider": "Apple iCloudi kalender", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloudi ülesanded", + "addAppleICloudAccount": "Lisa Apple iCloud Calendari konto", + "addNextcloudAccount": "Lisa Nextcloudi konto", + "waitingForAppleICloud": "Apple iCloudiga ühendamine…", + "waitingForNextcloud": "Nextcloudi autoriseerimise ootel…", + "connectAppleICloudTitle": "Ühenda Apple iCloud Calendar", + "appleAccountEmail": "Apple’i konto e-post", + "appleAppSpecificPassword": "Rakendusepõhine parool", + "appleAppSpecificPasswordHelp": "Loo rakendusepõhine parool pärast Apple’i konto kaheastmelise autentimise lubamist.", + "appleAppSpecificPasswordResetWarning": "Apple’i konto parooli lähtestamine tühistab rakendusepõhised paroolid.", + "connectNextcloudTitle": "Ühenda Nextcloud", + "nextcloudServerUrl": "Nextcloudi server või CalDAV-aadress", + "nextcloudServerUrlHelp": "Sisestage Nextcloudi serveri URL või kleepige Nextcloudist kopeeritud peamine CalDAV-aadress.", + "nextcloudBrowserAuthorizationHelp": "BusyMax avab brauseri. Kinnita juurdepääs seal ja naase seejärel BusyMaxi.", + "connectAccountAction": "Ühenda", + "cancelAccountConnection": "Tühista ühendus", + "nextcloudAccountRemovedRevokeFailed": "Konto eemaldati kohalikult, kuid Nextcloudi rakenduse parooli ei saanud tühistada.", + "davCachedOfflineNotice": "Kalendri- ja ülesandeandmed salvestatakse võrguühenduseta kasutamiseks kohapeal.", + "davReauthenticationRequired": "Sünkroonimise jätkamiseks ühenda see konto uuesti.", + "davTemporarilyUnavailable": "See konto pole ajutiselt saadaval.", + "davPermissionChanged": "Serveriõigused muutusid. Ootel muudatused on peatatud.", + "davUnsupportedServer": "Seda serverit või teenusepakkuja profiili ei toetata.", + "collectionSettings": "Kalendrid ja ülesandeloendid", + "calendarContent": "Kalendrisündmused", + "taskContent": "Ülesanded", + "readOnlySharedCollection": "Kirjutuskaitstud", + "pendingLocally": "Kohapeal ootel", + "conflictBlocked": "Konflikti tõttu blokeeritud", + "authenticationBlocked": "Blokeeritud kuni taasühendamiseni", + "operationFailed": "Toiming nurjus", + "keepServerVersion": "Säilita serveri versioon", + "reapplyLocalChange": "Vaata kohalik muudatus üle ja rakenda uuesti", + "duplicateLocalItem": "Dubleeri uue üksusena", "deleteCalendarConfirmation": "Kas kustutada „{title}”?", "@deleteCalendarConfirmation": { "placeholders": { @@ -593,11 +702,11 @@ } } }, - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "Ühenduse olek", + "davConnected": "Ühendatud", + "davConnecting": "Ühendamine…", + "davSignedOut": "Välja logitud", + "davLastSuccessfulSync": "Viimane õnnestunud sünkroonimine: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -605,9 +714,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "Veel sünkroonimata", + "refreshCollections": "Värskenda kalendreid ja ülesandeloendeid", + "nextcloudServerHost": "Serveri aadress: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -615,12 +724,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "Sündmuste kalender", + "collectionSupportsTasks": "Ülesandeloend", + "collectionSupportsEventsAndTasks": "Sündmused ja ülesanded", + "writableCollection": "Kirjutatav", + "sharedCollection": "Jagatud", + "collectionLastSynced": "Viimati sünkroonitud: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -628,7 +737,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "Sünkroonimisprobleem: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -636,8 +745,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "Sünkroonimiskonfliktid", + "remoteChangedAt": "Server muutis: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -645,7 +754,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "Kohalik muudatus: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -653,144 +762,366 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "conflictResolutionFailed": "Konflikti ei saanud lahendada.", + "recurringEventScope": "Korduva sündmuse ulatus", + "entireSeries": "Kogu sari", + "singleOccurrence": "See sündmus", + "thisAndFollowingEvents": "See ja järgmised sündmused", + "thisAndFutureUnavailable": "See teenusepakkuja ei toeta seda.", + "thisAndFutureMoveUnavailable": "Seda ja järgnevaid sündmusi ei saa turvaliselt teisaldada. Valige see sündmus või kogu sari.", + "entireSeriesMoveUnavailable": "Kordusreegel pole kohalikult saadaval. Teisalda selle asemel ainult see sündmus.", + "copyEventAndDeleteOriginal": "Kas kopeerida sündmus ja kustutada algne?", + "copyEventMoveWarning": "BusyMax ei saa seda sündmust otse kalendrist {source} kalendrisse {destination} teisaldada. Esmalt luuakse koopia ja algne sündmus kustutatakse alles pärast edukat kopeerimist. Sündmuse ID-d muutuvad; osalejate vastuseolekud võivad lähtestuda ning võidakse saata kutseid või tühistusi; konverentsilingid, manused, meeldetuletused, teenusepakkuja väljad ja korduse erandid ei pruugi üle kanduda.", + "copyAndDelete": "Kopeeri ja kustuta", + "chooseRecurringEventScope": "Valige, kas see muudatus rakendub kogu sarjale, ainult sellele sündmusele või sellele ja järgnevatele sündmustele.", "taskDueBeforeStart": "Tähtaeg ei tohi olla enne algust.", - "taskStartDueTimeModeMismatch": "Määra nii algus- kui ka tähtaja kellaaeg või tee ülesanne kogu päeva ülesandeks.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", - "unshare": "Unshare", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", - "taskStatusNeedsAction": "Needs action", - "taskStatusInProcess": "In process", - "taskStatusCompleted": "Completed", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", - "priority": "Priority", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "No reminders", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Before the task starts", - "beforeTaskDue": "Before the task is due", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "Repeat every", - "repeatOn": "Repeat on", - "repeatEnd": "End repeat", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", - "repeatOnMonthDaysSummary": "on day {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", - "repeatTimesSummary": "{count} times", - "repeatUntilSummary": "until {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", - "hideSubtasks": "Hide subtasks", - "hideClosedSubtasks": "Hide closed subtasks", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "taskStartDueTimeModeMismatch": "Määrake nii algus- kui ka tähtaja kellaaeg või tehke ülesanne kogu päeva ülesandeks.", + "taskListCreateFailed": "Ülesandeloendi loomine ebaõnnestus: {error}", + "taskListRenameFailed": "Ülesandeloendi ümbernimetamine ebaõnnestus: {error}", + "taskListDeleteFailed": "Ülesandeloendi kustutamine ebaõnnestus: {error}", + "unshare": "Lõpeta jagamine", + "readOnlyTaskListCannotRename": "See ülesandeloend on kirjutuskaitstud ja seda ei saa ümber nimetada.", + "taskListCannotDelete": "Seda ülesandeloendit ei saa praeguste õigustega kustutada.", + "deleteTaskListConfirmation": "Kas kustutada „{title}” ja kõik selle ülesanded?", + "unshareTaskListConfirmation": "Kas lõpetada „{title}” jagamine selle kontoga?", + "taskStatus": "Olek", + "taskStatusNone": "Olek puudub", + "taskStatusNeedsAction": "Vajab tegevust", + "taskStatusInProcess": "Töös", + "taskStatusCompleted": "Lõpetatud", + "taskStatusCancelled": "Tühistatud", + "completionPercent": "Lõpetatud {percent}%", + "completionDate": "Lõpetamise kuupäev", + "priority": "Prioriteet", + "priorityNone": "Prioriteet puudub", + "priorityHighValue": "Prioriteet {priority} · kõrge", + "priorityMediumValue": "Prioriteet {priority} · keskmine", + "priorityLowValue": "Prioriteet {priority} · madal", + "taskUrl": "Ülesande URL", + "invalidTaskUrl": "Sisestage absoluutne URL koos skeemiga.", + "classification": "Klassifikatsioon", + "classificationPublic": "Jagamisel näita kogu ülesannet", + "classificationConfidential": "Jagamisel näita ainult hõivatust", + "classificationPrivate": "Jagamisel peida see ülesanne", + "pinTask": "Kinnita ülesanne", + "reminders": "Meeldetuletused", + "noReminders": "Meeldetuletusi pole", + "editReminder": "Muuda meeldetuletust", + "beforeTaskStarts": "Enne ülesande algust", + "beforeTaskDue": "Enne ülesande tähtaega", + "afterTaskStarts": "Pärast ülesande algust", + "afterTaskDue": "Pärast ülesande tähtaega", + "relativeToTaskStart": "Ülesande alguskuupäeva suhtes", + "relativeToTaskDue": "Ülesande tähtaja suhtes", + "reminderTimeOfDay": "Kellaaeg", + "absoluteReminder": "Kuupäeval ja kellaajal", + "reminderAmount": "Kogus", + "reminderUnitSeconds": "Sekundit", + "reminderUnitMinutes": "Minutit", + "reminderUnitHours": "Tundi", + "reminderUnitDays": "Päeva", + "reminderUnitWeeks": "Nädalat", + "reminderAtTaskStart": "Ülesande alguses", + "reminderAtTaskDue": "Ülesande tähtajal", + "unsupportedReminder": "See meeldetuletuse tüüp säilitatakse, kuid selle aega ei saa muuta.", + "relatedRemindersTitle": "Kas säilitada seotud meeldetuletused?", + "relatedRemindersDescription": "Sellel kuupäeval on {count} seotud meeldetuletust. Kas säilitada need praegusel kuupäeval ja kellaajal?", + "discardRelatedReminders": "Hülga meeldetuletused", + "keepRelatedReminders": "Säilita meeldetuletused", + "repeatEvery": "Kordusintervall", + "repeatOn": "Kordub", + "repeatEnd": "Lõpeta kordus", + "repeatNever": "Mitte kunagi", + "repeatUntil": "Kuupäeval", + "repeatAfter": "Pärast korduste arvu täitumist", + "repeatCount": "Korduste arv", + "repeatDayOfMonth": "Kuu päevad", + "repeatMonths": "Kuud", + "repeatOrdinal": "Nädalapäeva järjekord", + "repeatSpecificDays": "Kindlad päevad", + "repeatFirst": "Esimene", + "repeatSecond": "Teine", + "repeatThird": "Kolmas", + "repeatFourth": "Neljas", + "repeatFifth": "Viies", + "repeatSecondToLast": "Eelviimane", + "repeatLast": "Viimane", + "repeatAnyDay": "Päev", + "repeatWeekday": "Tööpäev", + "repeatWeekendDay": "Nädalavahetuse päev", + "repeatOrdinalDaySummary": "{dayKey, select, MO{esmaspäev} TU{teisipäev} WE{kolmapäev} TH{neljapäev} FR{reede} SA{laupäev} SU{pühapäev} day{päev} weekday{tööpäev} weekend{nädalavahetuse päev} other{{day}}}", + "repeatEveryDays": "Iga {count} päeva järel", + "repeatEveryWeeks": "Iga {count} nädala järel", + "repeatEveryMonths": "Iga {count} kuu järel", + "repeatEveryYears": "Iga {count} aasta järel", + "repeatOnDaysSummary": "{days}", + "repeatOnMonthDaysSummary": "{days} päeval", + "repeatOnOrdinalSummary": "{position, select, first{esimene {days}} second{teine {days}} third{kolmas {days}} fourth{neljas {days}} fifth{viies {days}} secondToLast{eelviimane {days}} last{viimane {days}} other{{days}}}", + "repeatInMonthsSummary": "{months}", + "repeatTimesSummary": "{count, plural, one{{count} kord} other{{count} korda}}", + "repeatUntilSummary": "kuni kuupäevani {date}", + "unsupportedRecurrencePreserved": "See kordusreegel kasutab valikuid, mida see redaktor ei muuda.", + "recurrenceUnsupportedByProvider": "Seda kordust ei saa teenusega {provider} kasutada.", + "duplicateTask": "Loo ülesandest koopia", + "taskDuplicated": "Ülesanne dubleeriti.", + "taskDuplicateFailed": "Ülesande dubleerimine ebaõnnestus: {error}", + "hideSubtasks": "Peida alamülesanded", + "hideClosedSubtasks": "Peida lõpetatud alamülesanded", + "reminderUnit": "Ühik", + "attendeeRequired": "Nõutud", + "attendeeOptional": "Valikuline", + "meetingSection": "Koosolek", + "addGoogleMeet": "Lisa Google Meet", + "addTeamsMeeting": "Lisa Microsoft Teamsi koosolek", + "onlineMeetingAdded": "Veebikoosolek lisatud", + "requestResponses": "Küsi vastuseid", + "requestResponsesDescription": "Paluge külalistel kutsele vastata.", + "hideGuestList": "Peida külaliste loend", + "hideGuestListDescription": "Külalised ei näe, keda veel kutsuti.", + "allowNewTimeProposals": "Luba uusi ajapakkumisi", + "allowNewTimeProposalsDescription": "Külalised saavad soovitada muud koosolekuaega.", + "notifyGuestsTitle": "Kas teavitada külalisi?", + "notifyGuestsSaveMessage": "Sellel koosolekul on külalised. Kas saata salvestamisel kutsed või sündmuse uuendused?", + "notifyGuestsDeleteMessage": "Sellel koosolekul on külalised. Kas saata kustutamisel tühistus?", + "sendUpdates": "Saada uuendused", + "sendCancellation": "Saada tühistus", + "doNotSend": "Ära saada", + "microsoftNotifyGuestsSaveTitle": "Kas salvestada koosolek?", + "microsoftNotifyGuestsSaveMessage": "Microsoft saadab külalistele kutsed või sündmuse uuendused.", + "microsoftNotifyGuestsDeleteTitle": "Kas kustutada koosolek?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft saadab külalistele tühistuse.", + "organizer": "Korraldaja", + "yourResponse": "Sinu vastus", + "guestResponses": "Külaliste vastused", + "respond": "Vasta", + "acceptInvitation": "Nõustu", + "tentativeInvitation": "Esialgne", + "declineInvitation": "Keeldu", + "joinMeeting": "Liitu koosolekuga", + "responseAccepted": "Nõustutud", + "responseTentative": "Esialgne", + "responseDeclined": "Keeldutud", + "responseNeedsAction": "Vastust oodatakse", + "responseNotResponded": "Vastus puudub", + "responseOrganizer": "Korraldaja", + "invitationResponseFailed": "Sinu vastust ei saanud saata: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "Koosolekulinki ei saanud avada.", + "setCustomCalendarName": "Määra kohandatud nimi", + "setAction": "Määra", + "removeFromMyCalendars": "Eemalda minu kalendritest", + "removeAction": "Eemalda", + "removeCalendarConfirmation": "Kas eemaldada „{title}” teie Google’i kalendrite loendist? Jagatud kalendrit ega selle sündmusi ei kustutata.", + "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.", + "calendarSubscriptions": "Kalendritellimused", + "calendarSubscriptionsDescription": "Lisage kirjutuskaitstud kalendreid, mida värskendatakse turvalise WebCali URL-i kaudu.", + "addCalendarSubscription": "Lisa kalendritellimus", + "subscriptionName": "Kohalik nimi", + "subscriptionUrl": "Tellimuse URL", + "subscriptionUrlHelp": "Sisestage HTTPS- või webcal-URL. BusyMax hoiab täielikku URL-i turvalises salvestusruumis.", + "subscriptionUrlInvalid": "Sisestage kehtiv HTTPS- või webcal-URL ilma kasutajateabe või fragmendita.", + "subscriptionColor": "Kohalik värv", + "subscriptionColorHelp": "Kasutage kuuekohalist värvi, näiteks #3584E4.", + "subscriptionColorInvalid": "Sisestage kuuekohaline kuueteistkümnendsüsteemi värv.", + "subscriptionRefreshMode": "Värskendussagedus", + "subscriptionAutomatic": "Automaatne", + "subscriptionHourly": "Iga tunni järel", + "subscriptionSixHours": "Iga kuue tunni järel", + "subscriptionDaily": "Iga päev", + "subscriptionSafeOrigin": "Allikas: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Turvalise allika eelvaateks sisesta kehtiv URL.", + "subscriptionReadOnly": "Kirjutuskaitstud tellimus", + "subscriptionNeverRefreshed": "Veel värskendamata", + "subscriptionLastRefresh": "Viimane edukas värskendus: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Järgmine värskendus: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Ajaga kaasas", + "subscriptionStatusIssue": "Värskendusprobleem: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Värskenda kohe", + "unsubscribe": "Tühista tellimus", + "unsubscribeCalendarTitle": "Kas tühistada „{name}” tellimus?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "See eemaldab kohaliku tellimuse ja selle vahemällu salvestatud sündmused. Avaldatud kalendrit ei muudeta.", + "addSubscriptionAction": "Lisa tellimus", + "subscriptionOperationFailed": "Kalendritellimus nurjus: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Tellimused", + "calendarImport": "Kalendri import", + "calendarImportDescription": "Valige fail, vaadake selle sündmused üle ja valige seejärel kirjutatav kalender, kuhu need lisada.", + "importIcsFile": "Impordi .ics-fail", + "importIcsPreview": "Impordi kalendrisündmused", + "importEventsFound": "Imporditavad sündmusekomplektid: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Sobimatud sündmused: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Tahtlikult välja jäetud: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "Sobivat kirjutatavat sihtkalendrit pole.", + "importDestinationCalendar": "Sihtkalender", + "importIcsConfirm": "Impordi sündmused", + "importIcsComplete": "Import lõpetatud", + "importQueued": "Imporditud või järjekorda lisatud: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Vahele jäetud duplikaadid: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Toetamata korduskomplektid: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "Kalendrifaili ei saanud importida: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "Võrguühenduseta", "networkOfflineDescription": "Muudatused sünkroonitakse ühenduse taastumisel.", - "networkOfflineTryAgain": "Võrguühendus puudub. Looge internetiühendus ja proovige uuesti." + "networkOfflineTryAgain": "Võrguühendus puudub. Looge internetiühendus ja proovige uuesti.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "{days} päeval", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}.", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first} ja {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} ja {second}", + "repeatMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthValue": "{monthKey, select, jan{jaanuaris} feb{veebruaris} mar{märtsis} apr{aprillis} may{mais} jun{juunis} jul{juulis} aug{augustis} sep{septembris} oct{oktoobris} nov{novembris} dec{detsembris} other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} ja {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} ja {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} {month} {day} päeval", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} {months} {day} päeval", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} {months} {days} päeval", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {months}: {position, select, first{esimene} second{teine} third{kolmas} fourth{neljas} fifth{viies} secondToLast{eelviimane} last{viimane} other{}} {days}", + "repeatYearlyOnMonthDaysSummary": "{frequency} {month} {days} päeval", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {month}: {position, select, first{esimene} second{teine} third{kolmas} fourth{neljas} fifth{viies} secondToLast{eelviimane} last{viimane} other{}} {days}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 9db2c330..437d3396 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,14 +1,31 @@ { "@@locale": "fa", + "windowsSupport": "پشتیبانی", + "windowsThirdPartyLicenses": "مجوزهای شخص ثالث", + "windowsSearch": "جستجو", + "windowsStartupDisabledByUser": "کاربر این گزینه را در تنظیمات Windows غیرفعال کرده است.", + "windowsStartupDisabledByPolicy": "با خط‌مشی Windows غیرفعال شده است.", + "windowsStartupUnavailable": "پس از نصب BusyMax از بستهٔ MSIX در دسترس است.", + "windowsReminderExitNotice": "با خروج کامل از BusyMax یادآورها متوقف می‌شوند. برای دریافت آن‌ها برنامه را در پس‌زمینه فعال نگه دارید.", + "windowsProductVersionLabel": "نسخهٔ محصول", + "windowsPackageVersionLabel": "نسخهٔ بستهٔ Windows", + "windowsUnpackaged": "بدون بسته", + "windowsAgendaLoadMore": "بارگیری موارد بیشتر دستور کار", + "repeatWeeklyDaySummary": "{dayKey, select, MO{دوشنبه} TU{سه‌شنبه} WE{چهارشنبه} TH{پنجشنبه} FR{جمعه} SA{شنبه} SU{یکشنبه} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "در روزهای {first} و {second} ماه", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} در روزهای {firstDay} و {secondDay} ماه {month}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} در روز {day} ماه‌های {firstMonth} و {secondMonth}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} در روزهای {firstDay} و {secondDay} ماه‌های {firstMonth} و {secondMonth}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} در روزهای {days} ماه‌های {firstMonth} و {secondMonth}", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "حساب‌های Google، Microsoft، Apple iCloud Calendar یا Nextcloud را متصل کنید.", "googlePermissionsConsentNotice": "در صفحهٔ مجوزهای Google، مجوزهای تقویم و کارها را هر دو انتخاب کنید.", "googlePermissionsRequiredRetry": "مجوزهای Google Calendar و Google Tasks لازم هستند. دوباره تلاش کنید و هر دو کادر را علامت بزنید.", "finishSetup": "پایان راه‌اندازی", "continueSetup": "ادامه", "onboardingSetupTitle": "راه‌اندازی BusyMax", "onboardingAccountsStepTitle": "اتصال حساب‌ها", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "همهٔ حساب‌هایی را که می‌خواهید استفاده کنید اضافه کنید. BusyMax تقویم‌ها، رویدادها، فهرست‌های کار و کارهای پشتیبانی‌شده را از هر حساب همگام می‌کند.", "onboardingPreferencesStepTitle": "انتخاب تنظیمات سیستم", "onboardingPreferencesStepDescription": "پیش از باز کردن برنامه، رفتار برنامه روی میزکار، یادآورها، سطح جزئیات اعلان‌ها و ظاهر را تنظیم کنید.", "signInWithGoogle": "ورود با Google", @@ -33,6 +50,44 @@ "calendarTasks": "کارها", "calendar": "تقویم", "calendars": "تقویم‌ها", + "newCalendar": "تقویم جدید", + "calendarColor": "رنگ تقویم", + "calendarColorOption": "رنگ {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "calendarManagementUnsupported": "این ارائه‌دهنده از مدیریت تقویم در BusyMax پشتیبانی نمی‌کند.", + "primaryCalendarCannotDelete": "تقویم اصلی را نمی‌توان حذف کرد.", + "calendarCreateFailed": "ایجاد تقویم ممکن نشد: ⁨{error}⁩", + "calendarCreatedRefreshPending": "تقویم ایجاد شد، اما BusyMax نتوانست حساب را تازه‌سازی کند. پس از همگام‌سازی بعدی نمایش داده می‌شود.", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "به‌روزرسانی تقویم ممکن نشد: ⁨{error}⁩", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "حذف تقویم ممکن نشد: ⁨{error}⁩", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "رویداد جدید", "refreshCalendar": "تازه‌سازی تقویم", "openInProvider": "باز کردن در سرویس", @@ -52,6 +107,59 @@ "scheduleNoSearchResultsDescription": "جست‌وجوی دیگری را امتحان کنید یا پالایه‌های فعلی را پاک کنید.", "refresh": "تازه‌سازی", "trayOpenBusyMax": "باز کردن BusyMax", + "trayShowBusyMax": "نمایش BusyMax", + "trayNewEvent": "رویداد جدید…", + "trayNewTask": "کار جدید…", + "trayToday": "امروز", + "trayAllDay": "تمام‌روز", + "trayNow": "اکنون", + "trayCalendarEvent": "رویداد تقویم", + "trayUntitledEvent": "رویداد بدون عنوان", + "trayNothingElseToday": "امروز مورد دیگری نیست", + "trayTasksDueToday": "{count, plural, =1{۱ کار امروز سررسید دارد} other{{count} کار امروز سررسید دارند}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "trayOpenTodayAgenda": "باز کردن برنامهٔ امروز", + "traySyncNow": "همگام‌سازی اکنون", + "traySyncing": "در حال همگام‌سازی…", + "trayNotConnected": "متصل نیست", + "trayNotYetSynced": "هنوز همگام نشده", + "trayLastSyncedJustNow": "همین الان همگام شد", + "trayLastSyncedMinutesAgo": "{count, plural, =1{۱ دقیقه پیش همگام شد} other{{count} دقیقه پیش همگام شد}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{۱ ساعت پیش همگام شد} other{{count} ساعت پیش همگام شد}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{۱ روز پیش همگام شد} other{{count} روز پیش همگام شد}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "traySettings": "تنظیمات", + "trayQuitBusyMax": "خروج از BusyMax", "agendaLoadMoreOverdue": "بارگیری کارهای عقب‌افتادهٔ بیشتر", "agendaLoadMoreNoDate": "بارگیری کارهای بدون تاریخ بیشتر", "viewDay": "روز", @@ -78,7 +186,7 @@ "doesNotRepeat": "تکرار نمی‌شود", "defaultReminder": "یادآور پیش‌فرض", "guests": "مهمانان", - "noGuests": "بدون مهمان", + "noGuests": "فهرست مهمانان خالی است", "description": "توضیحات", "availabilityShowAs": "وضعیت دسترسی / نمایش به‌عنوان", "busy": "مشغول", @@ -109,7 +217,7 @@ "sensitivityPersonal": "شخصی", "tasks": "کارها", "allTasks": "همهٔ کارها", - "tasksInList": "کارهای ⁨{title}⁩", + "tasksInList": "کارهای ⁨⁨{title}⁩⁩", "taskLists": "فهرست‌های کار", "navigation": "پیمایش", "mainMenu": "منوی اصلی", @@ -154,7 +262,7 @@ "feedbackDetailedMessage": "پیام با جزئیات", "feedbackReplyEmail": "نشانی ایمیل برای پاسخ (اختیاری)", "feedbackIncludeTechnicalDetails": "افزودن جزئیات فنی", - "feedbackTechnicalDetailsDisclosure": "فقط نسخهٔ سیستم‌عامل Linux و تنظیمات منطقه‌ای برنامه افزوده می‌شود. هیچ گزارش، دادهٔ حساب، نام پرونده یا اطلاعات تشخیصی دیگری افزوده نمی‌شود.", + "feedbackTechnicalDetailsDisclosure": "فقط نام و نسخهٔ سیستم‌عامل و تنظیمات منطقه‌ای برنامه افزوده می‌شود. هیچ گزارش، دادهٔ حساب، نام پرونده یا اطلاعات تشخیصی دیگری افزوده نمی‌شود.", "feedbackCategoryRequired": "یک دسته‌بندی انتخاب کنید.", "feedbackSubjectLengthError": "موضوع باید بین ۳ تا ۱۲۰ نویسه باشد.", "feedbackMessageLengthError": "پیام باید بین ۱۰ تا ۵٬۰۰۰ نویسه باشد.", @@ -164,7 +272,7 @@ "feedbackRateLimitedError": "بازخوردهای بیش از حدی از این شبکه ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.", "feedbackRejectedError": "سرور ارسال را رد کرد. فیلدها را بررسی و دوباره تلاش کنید.", "feedbackServerError": "BusyStack اکنون نمی‌تواند بازخورد شما را بپذیرد. بازخورد شما پاک نشده است؛ دوباره تلاش کنید.", - "feedbackSuccess": "بازخورد ارسال شد. شناسهٔ پیگیری: ⁨{id}⁩", + "feedbackSuccess": "بازخورد ارسال شد. شناسهٔ پیگیری: ⁨⁨{id}⁩⁩", "toggleSidebar": "نمایش یا پنهان کردن نوار کناری", "showSidebar": "نمایش پنل کناری", "hideSidebar": "پنهان کردن پنل کناری", @@ -179,8 +287,8 @@ "removeAccount": "حذف حساب…", "removingAccount": "در حال حذف حساب…", "removeAccountDescription": "همگام‌سازی را متوقف و داده‌های این حساب را از این دستگاه حذف کنید.", - "removeAccountTitle": "حذف ⁨{account}⁩ از BusyMax؟", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountTitle": "⁨⁨{account}⁩⁩ از BusyMax حذف شود؟", + "removeAccountConfirmation": "این کار، کارها، تقویم‌ها، رویدادها، یادآورها و تغییرات آفلاین در انتظار را که در این دستگاه ذخیره شده‌اند حذف می‌کند. تغییرات همگام‌نشده از بین می‌روند. نسخه‌های تقویم‌ها، رویدادها، فهرست‌های کار و کارها نزد ارائه‌دهنده حذف نمی‌شوند.", "revokeGoogleAccess": "دسترسی BusyMax به این حساب Google نیز لغو شود", "revokeGoogleAccessDescription": "پیش از اتصال دوباره باید دسترسی را دوباره اعطا کنید.", "removeAccountAction": "حذف حساب", @@ -196,7 +304,7 @@ "deleteList": "حذف فهرست", "builtInMicrosoftList": "داخلی", "builtInMicrosoftListCannotRenameDelete": "فهرست‌های داخلی Microsoft To Do را نمی‌توان تغییر نام داد یا حذف کرد.", - "deleteListConfirmation": "«⁨{title}⁩» از Google Tasks حذف شود؟", + "deleteListConfirmation": "‏«⁨⁨{title}⁩⁩» از Google Tasks حذف شود؟", "deleteEvent": "حذف رویداد", "title": "عنوان", "create": "ایجاد", @@ -206,9 +314,9 @@ "refreshAll": "تازه‌سازی همه", "listRefreshed": "فهرست تازه‌سازی شد.", "allTasksRefreshed": "همهٔ حساب‌ها تازه‌سازی شدند.", - "exportedFile": "در ⁨{path}⁩ خروجی گرفته شد", - "exportFailed": "خروجی گرفتن ناموفق بود: ⁨{error}⁩", - "refreshFailed": "تازه‌سازی ناموفق بود: ⁨{error}⁩", + "exportedFile": "در ⁨⁨{path}⁩⁩ خروجی گرفته شد", + "exportFailed": "خروجی گرفتن ناموفق بود: ⁨⁨{error}⁩⁩", + "refreshFailed": "تازه‌سازی ناموفق بود: ⁨⁨{error}⁩⁩", "selectOrCreateTaskList": "برای شروع، یک فهرست کار انتخاب یا ایجاد کنید.", "signInToViewTasks": "برای دیدن کارها وارد شوید.", "noTasks": "هیچ کاری وجود ندارد.", @@ -221,8 +329,8 @@ "upcoming": "پیش رو", "noDate": "بدون تاریخ", "completed": "انجام‌شده", - "duePrefix": "سررسید: ⁨{date}⁩", - "dateTimeDisplay": "⁨{date}⁩ · ⁨{time}⁩", + "duePrefix": "سررسید: ⁨⁨{date}⁩⁩", + "dateTimeDisplay": "⁨⁨{date}⁩⁩ · ⁨⁨{time}⁩⁩", "taskDetails": "جزئیات کار", "editTask": "ویرایش کار", "noTaskSelected": "هیچ کاری انتخاب نشده است.", @@ -277,7 +385,7 @@ "moveToTop": "انتقال به بالاترین جایگاه", "deleteTask": "حذف کار", "newSubtask": "زیرکار جدید", - "deleteTaskConfirmation": "«⁨{title}⁩» حذف شود؟", + "deleteTaskConfirmation": "«⁨⁨{title}⁩⁩» حذف شود؟", "metadata": "فراداده", "id": "شناسه", "etag": "ETag", @@ -291,7 +399,8 @@ "synced": "همگام‌شده", "account": "حساب", "sync": "همگام‌سازی", - "manualFullSync": "همگام‌سازی کامل دستی", + "forceFullResync": "اجبار به همگام‌سازی مجدد کامل", + "forceFullResyncDescription": "همهٔ داده‌های هر حساب متصل را به‌طور کامل دوباره بارگیری می‌کند. فقط برای عیب‌یابی مشکلات همگام‌سازی از این گزینه استفاده کنید.", "runInBackgroundWhenClosed": "ادامهٔ اجرا پس از بسته شدن پنجره", "showTrayIcon": "نمایش نماد سینی سیستم", "startMinimizedToTray": "شروع به‌صورت کوچک‌شده در سینی سیستم", @@ -300,11 +409,12 @@ "launchAtLoginFailed": "تنظیم اجرای هنگام ورود به‌روزرسانی نشد.", "requiresTrayIcon": "به نماد سینی سیستم نیاز دارد.", "syncComplete": "همگام‌سازی کامل شد.", - "syncFailed": "همگام‌سازی ناموفق بود: ⁨{error}⁩", + "syncFailed": "همگام‌سازی ناموفق بود: ⁨⁨{error}⁩⁩", "notifySyncFailures": "اعلان هنگام شکست همگام‌سازی", "notifyConflicts": "اعلان هنگام تداخل", "notifyDueToday": "اعلان کارهای دارای سررسید امروز", "eventReminders": "یادآورهای رویداد", + "onState": "روشن", "taskReminders": "یادآورهای کار", "notificationDetailLevel": "سطح جزئیات اعلان", "notificationDetailPrivate": "خصوصی", @@ -314,9 +424,13 @@ "quietHoursStart": "شروع ساعات سکوت", "quietHoursEnd": "پایان ساعات سکوت", "notifications": "اعلان‌ها", + "windowsNotificationsUnavailable": "اعلان‌های Windows در دسترس نیستند", + "windowsNotificationsUnpackaged": "این اجرای توسعه‌ای بسته‌بندی‌نشده نمی‌تواند از اعلان‌های Windows استفاده کند. برای آزمایش یادآورها، MSIX امضاشدهٔ آزمایشی را نصب کنید.", + "windowsNotificationsInstalledFailure": "BusyMax نتوانست اعلان‌های Windows را راه‌اندازی کند. تا برطرف شدن این مشکل نصب، یادآورها نمایش داده نمی‌شوند.", "appearance": "ظاهر", "theme": "پوسته", "themeSystem": "سیستم", + "settingsSystem": "سیستم", "themeLight": "روشن", "themeDark": "تیره", "themeFamily": "خانوادهٔ پوسته", @@ -329,7 +443,7 @@ "diagnostics": "اطلاعات تشخیصی", "apiInspectorDisabled": "نمایش بازرس API", "googleTasksApi": "رابط Google Tasks API", - "discoveryRevision": "بازبینی Discovery: ⁨{revision}⁩", + "discoveryRevision": "بازبینی Discovery: ⁨⁨{revision}⁩⁩", "implementedMethods": "روش‌های پیاده‌سازی‌شده", "supportsTasksScopes": "از محدوده‌های tasks و tasks.readonly پشتیبانی می‌کند", "requiresTasksScope": "به محدودهٔ tasks نیاز دارد", @@ -337,8 +451,8 @@ "signInToInspectPendingOperations": "برای بررسی عملیات در انتظار وارد شوید.", "noBlockedPendingOperations": "هیچ عملیات در انتظار مسدودشده‌ای وجود ندارد.", "operationActions": "اقدامات عملیات", - "pendingOpListId": "فهرست=⁨{id}⁩", - "pendingOpTaskId": "کار=⁨{id}⁩", + "pendingOpListId": "فهرست=⁨⁨{id}⁩⁩", + "pendingOpTaskId": "کار=⁨⁨{id}⁩⁩", "pendingOpAttempts": "تلاش‌ها=⁨{count}⁩", "retry": "تلاش دوباره", "discard": "کنار گذاشتن", @@ -350,9 +464,9 @@ "discardPendingOperationConfirmation": "با این کار عملیات محلی مسدودشده حذف می‌شود. در همگام‌سازی بعدی، داده‌ها از Google Tasks تازه‌سازی می‌شوند.", "pendingOperationDiscarded": "عملیات در انتظار کنار گذاشته شد.", "syncFailureNotificationTitle": "همگام‌سازی BusyMax ناموفق بود", - "syncFailureNotificationBody": "همگام‌سازی پس‌زمینه ناموفق بود. ⁨{message}⁩", + "syncFailureNotificationBody": "همگام‌سازی پس‌زمینه ناموفق بود. ⁨⁨{message}⁩⁩", "conflictNotificationTitle": "تداخل همگام‌سازی BusyMax", - "conflictNotificationBody": "یک تغییر محلی در انتظار مسدود شد. ⁨{summary}⁩", + "conflictNotificationBody": "یک تغییر محلی در انتظار مسدود شد. ⁨⁨{summary}⁩⁩", "dueTodayNotificationTitle": "کارهای دارای سررسید امروز", "dueTodayNotificationBody": "{count, plural, =0{امروز هیچ کاری سررسید ندارد.} =1{امروز یک کار سررسید دارد.} other{امروز ⁨{count}⁩ کار سررسید دارند.}}", "eventReminderNotificationTitle": "یادآور رویداد", @@ -361,7 +475,7 @@ "taskReminderNotificationBody": "سررسید کار نزدیک است.", "notificationOpenAction": "باز کردن", "notificationSnoozeAction": "تعویق ۱۰ دقیقه‌ای", - "notificationDismissAction": "رد کردن", + "notificationDismissAction": "بستن", "notificationDetailsHidden": "جزئیات به‌دلیل تنظیمات حریم خصوصی پنهان شده‌اند.", "previousMonth": "ماه قبل", "nextMonth": "ماه بعد", @@ -376,45 +490,45 @@ "selectTimeZone": "انتخاب منطقهٔ زمانی", "searchLocations": "جست‌وجوی مکان‌ها", "noLocationsFound": "مکانی پیدا نشد", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "این فیلد الزامی است.", + "providerConnectionDescription": "تقویم‌ها و کارها را از یکی از این ارائه‌دهندگان متصل کنید.", + "appleICloudProvider": "تقویم Apple iCloud", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", - "deleteCalendarConfirmation": "«⁨{title}⁩» حذف شود؟", + "nextcloudTasksProvider": "کارهای Nextcloud", + "addAppleICloudAccount": "افزودن حساب تقویم Apple iCloud", + "addNextcloudAccount": "افزودن حساب Nextcloud", + "waitingForAppleICloud": "در حال اتصال به Apple iCloud…", + "waitingForNextcloud": "در انتظار تأیید Nextcloud…", + "connectAppleICloudTitle": "اتصال تقویم Apple iCloud", + "appleAccountEmail": "ایمیل حساب Apple", + "appleAppSpecificPassword": "گذرواژهٔ اختصاصی برنامه", + "appleAppSpecificPasswordHelp": "پس از فعال کردن احراز هویت دومرحله‌ای برای حساب Apple، یک گذرواژهٔ اختصاصی برنامه بسازید.", + "appleAppSpecificPasswordResetWarning": "بازنشانی گذرواژهٔ حساب Apple، گذرواژه‌های اختصاصی برنامه را باطل می‌کند.", + "connectNextcloudTitle": "اتصال Nextcloud", + "nextcloudServerUrl": "سرور Nextcloud یا نشانی CalDAV", + "nextcloudServerUrlHelp": "URL سرور Nextcloud خود را وارد کنید یا نشانی اصلی CalDAV را که از Nextcloud کپی کرده‌اید بچسبانید.", + "nextcloudBrowserAuthorizationHelp": "BusyMax مرورگر شما را باز می‌کند. آنجا دسترسی را تأیید کنید و سپس به BusyMax برگردید.", + "connectAccountAction": "اتصال", + "cancelAccountConnection": "لغو اتصال", + "nextcloudAccountRemovedRevokeFailed": "حساب به‌صورت محلی حذف شد، اما ابطال گذرواژهٔ برنامهٔ Nextcloud ممکن نیست.", + "davCachedOfflineNotice": "داده‌های تقویم و کار برای استفادهٔ آفلاین به‌صورت محلی ذخیره می‌شوند.", + "davReauthenticationRequired": "برای ادامهٔ همگام‌سازی، این حساب را دوباره متصل کنید.", + "davTemporarilyUnavailable": "این حساب موقتاً در دسترس نیست.", + "davPermissionChanged": "مجوزهای سرور تغییر کرده‌اند. ویرایش‌های در انتظار متوقف شده‌اند.", + "davUnsupportedServer": "این سرور یا نمایهٔ ارائه‌دهنده پشتیبانی نمی‌شود.", + "collectionSettings": "تقویم‌ها و فهرست‌های کار", + "calendarContent": "رویدادهای تقویم", + "taskContent": "کارها", + "readOnlySharedCollection": "فقط‌خواندنی", + "pendingLocally": "در انتظار محلی", + "conflictBlocked": "مسدودشده به‌دلیل تعارض", + "authenticationBlocked": "تا اتصال دوباره مسدود است", + "operationFailed": "عملیات ناموفق بود", + "keepServerVersion": "نگه‌داشتن نسخهٔ سرور", + "reapplyLocalChange": "بررسی و اعمال دوبارهٔ تغییر محلی", + "duplicateLocalItem": "تکرار به‌عنوان مورد جدید", + "deleteCalendarConfirmation": "«⁨⁨{title}⁩⁩» حذف شود؟", "@moreItems": { "placeholders": { "count": { @@ -479,11 +593,11 @@ } } }, - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "وضعیت اتصال", + "davConnected": "متصل", + "davConnecting": "در حال اتصال…", + "davSignedOut": "خارج‌شده", + "davLastSuccessfulSync": "آخرین همگام‌سازی موفق: ⁨{time}⁩", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -491,9 +605,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "هنوز همگام نشده", + "refreshCollections": "تازه‌سازی تقویم‌ها و فهرست‌های کار", + "nextcloudServerHost": "سرور: ⁨{host}⁩", "@nextcloudServerHost": { "placeholders": { "host": { @@ -501,12 +615,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "تقویم رویدادها", + "collectionSupportsTasks": "فهرست کار", + "collectionSupportsEventsAndTasks": "رویدادها و کارها", + "writableCollection": "قابل نوشتن", + "sharedCollection": "اشتراکی", + "collectionLastSynced": "آخرین همگام‌سازی: ⁨{time}⁩", "@collectionLastSynced": { "placeholders": { "time": { @@ -514,7 +628,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "مشکل همگام‌سازی: ⁨{code}⁩", "@collectionSyncError": { "placeholders": { "code": { @@ -522,8 +636,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "تعارض‌های همگام‌سازی", + "remoteChangedAt": "تغییر سرور: ⁨{time}⁩", "@remoteChangedAt": { "placeholders": { "time": { @@ -531,7 +645,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "ویرایش محلی: ⁨{summary}⁩", "@localPendingEdit": { "placeholders": { "summary": { @@ -539,144 +653,371 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "conflictResolutionFailed": "حل تعارض ممکن نیست.", + "recurringEventScope": "دامنهٔ رویداد تکرارشونده", + "entireSeries": "کل مجموعه", + "singleOccurrence": "این رویداد", + "thisAndFollowingEvents": "این رویداد و رویدادهای بعدی", + "thisAndFutureUnavailable": "این ارائه‌دهنده از آن پشتیبانی نمی‌کند.", + "thisAndFutureMoveUnavailable": "انتقال امن این رویداد و رویدادهای بعدی ممکن نیست. این رویداد یا کل مجموعه را انتخاب کنید.", + "entireSeriesMoveUnavailable": "قاعدهٔ تکرار به‌صورت محلی در دسترس نیست. به‌جای آن فقط این رویداد را منتقل کنید.", + "copyEventAndDeleteOriginal": "رویداد کپی و نسخهٔ اصلی حذف شود؟", + "copyEventMoveWarning": "BusyMax نمی‌تواند این رویداد را مستقیماً از ⁨{source}⁩ به ⁨{destination}⁩ منتقل کند. ابتدا کپی را ایجاد می‌کند و فقط پس از موفقیت کپی، نسخهٔ اصلی را حذف می‌کند. شناسه‌های رویداد تغییر می‌کنند؛ ممکن است وضعیت پاسخ شرکت‌کنندگان بازنشانی شود و دعوت‌نامه یا لغو ارسال شود؛ و شاید پیوندهای جلسه، پیوست‌ها، یادآورها، فیلدهای ویژهٔ ارائه‌دهنده و استثناهای تکرار منتقل نشوند.", + "copyAndDelete": "کپی و حذف", + "chooseRecurringEventScope": "انتخاب کنید این تغییر برای کل مجموعه، فقط این رویداد، یا این رویداد و رویدادهای بعدی اعمال شود.", "taskDueBeforeStart": "زمان سررسید نباید پیش از زمان شروع باشد.", "taskStartDueTimeModeMismatch": "برای شروع و سررسید هر دو زمان تعیین کنید، یا کار را تمام‌روز کنید.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskListCreateFailed": "ایجاد فهرست کار ممکن نیست: ⁨{error}⁩", + "taskListRenameFailed": "تغییر نام فهرست کار ممکن نیست: ⁨{error}⁩", + "taskListDeleteFailed": "حذف فهرست کار ممکن نیست: ⁨{error}⁩", "unshare": "لغو اشتراک‌گذاری", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", - "taskStatusNeedsAction": "نیاز به اقدام", + "readOnlyTaskListCannotRename": "این فهرست کار فقط‌خواندنی است و نمی‌توان نام آن را تغییر داد.", + "taskListCannotDelete": "این فهرست کار با مجوزهای فعلی شما قابل حذف نیست.", + "deleteTaskListConfirmation": "‏«⁨{title}⁩» و همهٔ کارهای آن حذف شوند؟", + "unshareTaskListConfirmation": "اشتراک‌گذاری «⁨{title}⁩» از این حساب لغو شود؟", + "taskStatus": "وضعیت", + "taskStatusNone": "بدون وضعیت", + "taskStatusNeedsAction": "نیازمند اقدام", "taskStatusInProcess": "در حال انجام", "taskStatusCompleted": "انجام‌شده", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusCancelled": "لغوشده", + "completionPercent": "{percent}٪ تکمیل‌شده", + "completionDate": "تاریخ تکمیل", "priority": "اولویت", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "بدون یادآوری", - "editReminder": "Edit reminder", - "beforeTaskStarts": "قبل از شروع کار", - "beforeTaskDue": "قبل از موعد انجام کار", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "حذف یادآوری‌ها", - "keepRelatedReminders": "نگه‌داشتن یادآوری‌ها", - "repeatEvery": "تکرار هر", - "repeatOn": "Repeat on", + "priorityNone": "بدون اولویت", + "priorityHighValue": "اولویت {priority} · زیاد", + "priorityMediumValue": "اولویت {priority} · متوسط", + "priorityLowValue": "اولویت {priority} · کم", + "taskUrl": "URL کار", + "invalidTaskUrl": "یک URL مطلق شامل طرح آن وارد کنید.", + "classification": "دسته‌بندی", + "classificationPublic": "هنگام اشتراک‌گذاری، کل کار نشان داده شود", + "classificationConfidential": "هنگام اشتراک‌گذاری، فقط مشغول بودن نشان داده شود", + "classificationPrivate": "هنگام اشتراک‌گذاری، این کار پنهان شود", + "pinTask": "سنجاق کردن کار", + "reminders": "یادآورها", + "noReminders": "بدون یادآور", + "editReminder": "ویرایش یادآور", + "beforeTaskStarts": "پیش از شروع کار", + "beforeTaskDue": "پیش از سررسید کار", + "afterTaskStarts": "پس از شروع کار", + "afterTaskDue": "پس از سررسید کار", + "relativeToTaskStart": "نسبت به تاریخ شروع کار", + "relativeToTaskDue": "نسبت به تاریخ سررسید کار", + "reminderTimeOfDay": "زمان روز", + "absoluteReminder": "در تاریخ و زمان مشخص", + "reminderAmount": "مقدار", + "reminderUnitSeconds": "ثانیه", + "reminderUnitMinutes": "دقیقه", + "reminderUnitHours": "ساعت", + "reminderUnitDays": "روز", + "reminderUnitWeeks": "هفته", + "reminderAtTaskStart": "هنگام شروع کار", + "reminderAtTaskDue": "هنگام سررسید کار", + "unsupportedReminder": "نوع این یادآور حفظ می‌شود، اما زمان آن قابل ویرایش نیست.", + "relatedRemindersTitle": "یادآورهای مرتبط نگه داشته شوند؟", + "relatedRemindersDescription": "این تاریخ {count} یادآور مرتبط دارد. آن‌ها در تاریخ و زمان فعلی نگه داشته شوند؟", + "discardRelatedReminders": "دور انداختن یادآورها", + "keepRelatedReminders": "نگه‌داشتن یادآورها", + "repeatEvery": "فاصلهٔ تکرار", + "repeatOn": "تکرار در", "repeatEnd": "پایان تکرار", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", + "repeatNever": "هرگز", + "repeatUntil": "در تاریخ", + "repeatAfter": "پس از تعداد مشخصی تکرار", + "repeatCount": "تعداد تکرار", + "repeatDayOfMonth": "روزهای ماه", + "repeatMonths": "ماه‌ها", + "repeatOrdinal": "جایگاه روز هفته", + "repeatSpecificDays": "روزهای مشخص", + "repeatFirst": "اول", + "repeatSecond": "دوم", + "repeatThird": "سوم", + "repeatFourth": "چهارم", + "repeatFifth": "پنجم", + "repeatSecondToLast": "یکی مانده به آخر", + "repeatLast": "آخر", + "repeatAnyDay": "روز", + "repeatWeekday": "روز هفته", + "repeatWeekendDay": "روز آخر هفته", + "repeatOrdinalDaySummary": "{dayKey, select, MO{دوشنبه} TU{سه‌شنبه} WE{چهارشنبه} TH{پنجشنبه} FR{جمعه} SA{شنبه} SU{یکشنبه} day{روز} weekday{روز هفته} weekend{روز آخر هفته} other{{day}}}", + "repeatEveryDays": "هر {count} روز", + "repeatEveryWeeks": "هر {count} هفته", + "repeatEveryMonths": "هر {count} ماه", + "repeatEveryYears": "هر {count} سال", "repeatOnDaysSummary": "در {days}", - "repeatOnMonthDaysSummary": "در روز {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", + "repeatOnMonthDaysSummary": "در روز {days} ماه", + "repeatOnOrdinalSummary": "{position, select, first{در اولین {days}} second{در دومین {days}} third{در سومین {days}} fourth{در چهارمین {days}} fifth{در پنجمین {days}} secondToLast{در یکی‌مانده‌به‌آخرین {days}} last{در آخرین {days}} other{در {days}}}", "repeatInMonthsSummary": "در {months}", - "repeatTimesSummary": "{count} بار", - "repeatUntilSummary": "تا {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", - "duplicateTask": "تکراری‌سازی وظیفه", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", - "hideSubtasks": "مخفی‌سازی زیروظیفه‌ها", - "hideClosedSubtasks": "مخفی‌سازی زیروظیفه‌های بسته‌شده", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "repeatTimesSummary": "{count, plural, one{{count} بار} other{{count} بار}}", + "repeatUntilSummary": "تا ⁨{date}⁩", + "unsupportedRecurrencePreserved": "این قانون تکرار از گزینه‌هایی استفاده می‌کند که این ویرایشگر تغییر نمی‌دهد.", + "recurrenceUnsupportedByProvider": "این تکرار را نمی‌توان با ⁨{provider}⁩ استفاده کرد.", + "duplicateTask": "تکرار کار", + "taskDuplicated": "کار تکرار شد.", + "taskDuplicateFailed": "تکرار کار ممکن نیست: ⁨{error}⁩", + "hideSubtasks": "پنهان کردن زیروظیفه‌ها", + "hideClosedSubtasks": "پنهان کردن زیروظیفه‌های بسته‌شده", + "reminderUnit": "واحد", + "attendeeRequired": "ضروری", + "attendeeOptional": "اختیاری", + "meetingSection": "جلسه", + "addGoogleMeet": "افزودن Google Meet", + "addTeamsMeeting": "افزودن جلسهٔ Microsoft Teams", + "onlineMeetingAdded": "جلسهٔ آنلاین افزوده شد", + "requestResponses": "درخواست پاسخ", + "requestResponsesDescription": "از مهمانان بخواهید به دعوت‌نامه پاسخ دهند.", + "hideGuestList": "پنهان کردن فهرست مهمانان", + "hideGuestListDescription": "مهمانان نمی‌توانند ببینند چه افراد دیگری دعوت شده‌اند.", + "allowNewTimeProposals": "اجازهٔ پیشنهاد زمان‌های جدید", + "allowNewTimeProposalsDescription": "مهمانان می‌توانند زمان دیگری برای جلسه پیشنهاد کنند.", + "notifyGuestsTitle": "مهمانان اطلاع داده شوند؟", + "notifyGuestsSaveMessage": "این جلسه مهمان دارد. هنگام ذخیره، دعوت‌نامه یا به‌روزرسانی رویداد برای آن‌ها ارسال شود؟", + "notifyGuestsDeleteMessage": "این جلسه مهمان دارد. هنگام حذف، لغو جلسه ارسال شود؟", + "sendUpdates": "ارسال به‌روزرسانی‌ها", + "sendCancellation": "ارسال لغو", + "doNotSend": "ارسال نکن", + "microsoftNotifyGuestsSaveTitle": "جلسه ذخیره شود؟", + "microsoftNotifyGuestsSaveMessage": "Microsoft دعوت‌نامه یا به‌روزرسانی رویداد را برای مهمانان ارسال می‌کند.", + "microsoftNotifyGuestsDeleteTitle": "جلسه حذف شود؟", + "microsoftNotifyGuestsDeleteMessage": "Microsoft لغو جلسه را برای مهمانان ارسال می‌کند.", + "organizer": "برگزارکننده", + "yourResponse": "پاسخ شما", + "guestResponses": "پاسخ مهمانان", + "respond": "پاسخ دادن", + "acceptInvitation": "پذیرفتن", + "tentativeInvitation": "موقت", + "declineInvitation": "رد کردن", + "joinMeeting": "پیوستن به جلسه", + "responseAccepted": "پذیرفته‌شده", + "responseTentative": "موقت", + "responseDeclined": "ردشده", + "responseNeedsAction": "در انتظار پاسخ", + "responseNotResponded": "بی‌پاسخ", + "responseOrganizer": "برگزارکننده", + "invitationResponseFailed": "ارسال پاسخ شما ممکن نیست: ⁨{error}⁩", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "باز کردن پیوند جلسه ممکن نیست.", + "setCustomCalendarName": "تنظیم نام سفارشی", + "setAction": "تنظیم", + "removeFromMyCalendars": "حذف از تقویم‌های من", + "removeAction": "حذف", + "removeCalendarConfirmation": "«⁨{title}⁩» از فهرست تقویم Google شما حذف شود؟ تقویم اشتراکی و رویدادهای آن حذف نخواهند شد.", + "calendarCannotRemove": "این تقویم را نمی‌توان از این حساب حذف یا جدا کرد.", + "calendarPendingChangesPreventRemoval": "پیش از حذف یا جدا کردن این تقویم، صبر کنید تغییرات در انتظار آن همگام شوند.", + "calendarSubscriptions": "اشتراک‌های تقویم", + "calendarSubscriptionsDescription": "تقویم‌های فقط‌خواندنی را اضافه کنید که از یک URL امن WebCal تازه می‌شوند.", + "addCalendarSubscription": "افزودن اشتراک تقویم", + "subscriptionName": "نام محلی", + "subscriptionUrl": "URL اشتراک", + "subscriptionUrlHelp": "یک URL از نوع HTTPS یا webcal وارد کنید. BusyMax URL کامل را در فضای ذخیره‌سازی امن نگه می‌دارد.", + "subscriptionUrlInvalid": "یک URL معتبر HTTPS یا webcal بدون اطلاعات کاربر یا قطعه وارد کنید.", + "subscriptionColor": "رنگ محلی", + "subscriptionColorHelp": "یک رنگ شش‌رقمی مانند #3584E4 وارد کنید.", + "subscriptionColorInvalid": "یک رنگ هگزادسیمال شش‌رقمی وارد کنید.", + "subscriptionRefreshMode": "دفعات تازه‌سازی", + "subscriptionAutomatic": "خودکار", + "subscriptionHourly": "ساعتی", + "subscriptionSixHours": "هر شش ساعت", + "subscriptionDaily": "روزانه", + "subscriptionSafeOrigin": "مبدأ: ⁨{origin}⁩", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "برای پیش‌نمایش مبدأ امن، یک URL معتبر وارد کنید.", + "subscriptionReadOnly": "اشتراک فقط‌خواندنی", + "subscriptionNeverRefreshed": "هنوز تازه نشده", + "subscriptionLastRefresh": "آخرین تازه‌سازی موفق: ⁨{time}⁩", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "تازه‌سازی بعدی: ⁨{time}⁩", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "به‌روز", + "subscriptionStatusIssue": "مشکل تازه‌سازی: ⁨{code}⁩", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "تازه‌سازی اکنون", + "unsubscribe": "لغو اشتراک", + "unsubscribeCalendarTitle": "اشتراک «⁨{name}⁩» لغو شود؟", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "این کار اشتراک محلی و رویدادهای ذخیره‌شدهٔ آن را حذف می‌کند. تقویم منتشرشده تغییر نمی‌کند.", + "addSubscriptionAction": "افزودن اشتراک", + "subscriptionOperationFailed": "اشتراک تقویم ناموفق بود: ⁨{error}⁩", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "اشتراک‌ها", + "calendarImport": "درون‌ریزی تقویم", + "calendarImportDescription": "یک فایل انتخاب کنید، رویدادهای آن را بررسی کنید و سپس تقویم قابل نوشتنی را که باید آن‌ها را دریافت کند انتخاب کنید.", + "importIcsFile": "درون‌ریزی فایل .ics", + "importIcsPreview": "درون‌ریزی رویدادهای تقویم", + "importEventsFound": "مجموعه رویدادهای قابل درون‌ریزی: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "importInvalidEvents": "رویدادهای نامعتبر: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "importFieldsOmitted": "عمداً حذف‌شده: ⁨{fields}⁩", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "تقویم مقصد قابل نوشتنی موجود نیست.", + "importDestinationCalendar": "تقویم مقصد", + "importIcsConfirm": "درون‌ریزی رویدادها", + "importIcsComplete": "درون‌ریزی کامل شد", + "importQueued": "درون‌ریزی‌شده یا در صف: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "importDuplicatesSkipped": "تکراری‌ها رد شدند: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "importUnsupportedSets": "مجموعه‌های تکرار پشتیبانی‌نشده: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "importIcsFailed": "درون‌ریزی فایل تقویم ممکن نیست: ⁨{error}⁩", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "آفلاین", "networkOfflineDescription": "تغییرات پس از برقراری دوبارهٔ اتصال همگام‌سازی می‌شوند.", - "networkOfflineTryAgain": "آفلاین هستید. به اینترنت متصل شوید و دوباره تلاش کنید." + "networkOfflineTryAgain": "آفلاین هستید. به اینترنت متصل شوید و دوباره تلاش کنید.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "در روزهای {days} ماه", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first} و {second}", + "repeatWeekdayListStart": "{first}، {rest}", + "repeatMonthDayListPair": "{first} و {second}", + "repeatMonthDayListStart": "{first}، {rest}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} و {second}", + "repeatYearlyMonthDayListStart": "{first}، {rest}", + "repeatYearlyMonthListPair": "{first} و {second}", + "repeatYearlyMonthListStart": "{first}، {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} در روز {day} ماه {month}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} در روز {day} ماه‌های {months}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} در روزهای {days} ماه‌های {months}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} در {position, select, first{اولین} second{دومین} third{سومین} fourth{چهارمین} fifth{پنجمین} secondToLast{یکی‌مانده‌به‌آخرین} last{آخرین} other{}} {days} ماه‌های {months}", + "repeatYearlyOnMonthDaysSummary": "{frequency} در روزهای {days} ماه {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} در {position, select, first{اولین} second{دومین} third{سومین} fourth{چهارمین} fifth{پنجمین} secondToLast{یکی‌مانده‌به‌آخرین} last{آخرین} other{}} {days} ماه {month}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } } diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 74225053..c406a27c 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -1,14 +1,31 @@ { "@@locale": "fi", + "windowsSupport": "Tuki", + "windowsThirdPartyLicenses": "Kolmannen osapuolen lisenssit", + "windowsSearch": "Haku", + "windowsStartupDisabledByUser": "Käyttäjä on poistanut toiminnon käytöstä Windowsin asetuksissa.", + "windowsStartupDisabledByPolicy": "Windows-käytäntö on poistanut toiminnon käytöstä.", + "windowsStartupUnavailable": "Käytettävissä, kun BusyMax on asennettu MSIX-paketista.", + "windowsReminderExitNotice": "Muistutukset lakkaavat, kun BusyMax suljetaan kokonaan. Jätä se toimimaan taustalle saadaksesi muistutukset.", + "windowsProductVersionLabel": "Tuoteversio", + "windowsPackageVersionLabel": "Windows-paketin versio", + "windowsUnpackaged": "Pakkaamaton", + "windowsAgendaLoadMore": "Lataa lisää esityslistan kohteita", + "repeatWeeklyDaySummary": "{dayKey, select, MO{maanantaisin} TU{tiistaisin} WE{keskiviikkoisin} TH{torstaisin} FR{perjantaisin} SA{lauantaisin} SU{sunnuntaisin} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "kuukauden {first} ja {second} päivänä", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} {month} {firstDay} ja {secondDay} päivänä", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} {firstMonth} ja {secondMonth} {day} päivänä", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} {firstMonth} ja {secondMonth} {firstDay} ja {secondDay} päivänä", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} {firstMonth} ja {secondMonth} {days} päivänä", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Yhdistä Google-, Microsoft-, Apple iCloud Calendar- tai Nextcloud-tilit.", "googlePermissionsConsentNotice": "Valitse Googlen käyttöoikeusnäkymässä sekä Kalenteri- että Tehtävät-käyttöoikeudet.", "googlePermissionsRequiredRetry": "Google Kalenterin ja Google Tasksin käyttöoikeudet vaaditaan. Yritä uudelleen ja valitse molemmat valintaruudut.", "finishSetup": "Viimeistele määritys", "continueSetup": "Jatka", "onboardingSetupTitle": "Määritä BusyMax", "onboardingAccountsStepTitle": "Yhdistä tilit", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "Lisää kaikki tilit, joita haluat käyttää. BusyMax synkronoi tuetut kalenterit, tapahtumat, tehtäväluettelot ja tehtävät jokaiselta tililtä.", "onboardingPreferencesStepTitle": "Valitse järjestelmäasetukset", "onboardingPreferencesStepDescription": "Määritä sovelluksen toiminta työpöydällä, muistutukset, ilmoitusten yksityiskohtaisuus ja ulkoasu ennen aikataulun avaamista.", "signInWithGoogle": "Kirjaudu Google-tilillä", @@ -33,6 +50,43 @@ "calendarTasks": "Tehtävät", "calendar": "Kalenteri", "calendars": "Kalenterit", + "newCalendar": "Uusi kalenteri", + "calendarColor": "Kalenterin väri", + "calendarColorOption": "Väri {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "Tämä palveluntarjoaja ei tue kalenterien hallintaa BusyMaxissa.", + "primaryCalendarCannotDelete": "Ensisijaista kalenteria ei voi poistaa.", + "calendarCreateFailed": "Kalenteria ei voitu luoda: {error}", + "calendarCreatedRefreshPending": "Kalenteri luotiin, mutta BusyMax ei voinut päivittää tiliä. Se tulee näkyviin seuraavan synkronoinnin jälkeen.", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "Kalenteria ei voitu päivittää: {error}", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "Kalenteria ei voitu poistaa: {error}", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "Uusi tapahtuma", "refreshCalendar": "Päivitä kalenteri", "openInProvider": "Avaa palvelussa", @@ -40,7 +94,7 @@ "showInSchedule": "Näytä aikataulussa", "noCalendarsSynced": "Kalentereita ei ole vielä synkronoitu.", "allDay": "Koko päivä", - "moreItems": "+{count} muuta", + "moreItems": "{count, plural, one{+1 muu} other{+{count} muuta}}", "noEventsOrTasks": "Ei tapahtumia tai tehtäviä", "scheduleLoading": "Ladataan aikataulua...", "scheduleUnavailable": "Aikataulu ei ole käytettävissä", @@ -52,13 +106,62 @@ "scheduleNoSearchResultsDescription": "Kokeile toista hakua tai tyhjennä nykyiset suodattimet.", "refresh": "Päivitä", "trayOpenBusyMax": "Avaa BusyMax", + "trayShowBusyMax": "Näytä BusyMax", + "trayNewEvent": "Uusi tapahtuma…", + "trayNewTask": "Uusi tehtävä…", + "trayToday": "Tänään", + "trayAllDay": "Koko päivä", + "trayNow": "Nyt", + "trayCalendarEvent": "Kalenteritapahtuma", + "trayUntitledEvent": "Nimetön tapahtuma", + "trayNothingElseToday": "Ei muuta tänään", + "trayTasksDueToday": "{count, plural, =1{1 tehtävä erääntyy tänään} other{{count} tehtävää erääntyy tänään}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Avaa tämän päivän agenda", + "traySyncNow": "Synkronoi nyt", + "traySyncing": "Synkronoidaan…", + "trayNotConnected": "Ei yhteyttä", + "trayNotYetSynced": "Ei vielä synkronoitu", + "trayLastSyncedJustNow": "Synkronoitu juuri nyt", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Synkronoitu minuutti sitten} other{Synkronoitu {count} minuuttia sitten}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Synkronoitu tunti sitten} other{Synkronoitu {count} tuntia sitten}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Synkronoitu päivä sitten} other{Synkronoitu {count} päivää sitten}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Asetukset", + "trayQuitBusyMax": "Lopeta BusyMax", "agendaLoadMoreOverdue": "Lataa lisää myöhässä olevia tehtäviä", "agendaLoadMoreNoDate": "Lataa lisää päiväämättömiä tehtäviä", "viewDay": "Päivä", "viewWeek": "Viikko", "viewMonth": "Kuukausi", "viewYear": "Vuosi", - "viewAgenda": "Agenda", + "viewAgenda": "Agendanäkymä", "scheduleSettings": "Aikataulu", "scheduleDisplaySettings": "Aikataulun näyttö", "scheduleDisplayHoursDescription": "Päivä- ja viikkonäkymät näyttävät aluksi tämän aikavälin. Aikaisemmat ja myöhemmät kohteet laajentavat sitä tarvittaessa.", @@ -154,7 +257,7 @@ "feedbackDetailedMessage": "Yksityiskohtainen viesti", "feedbackReplyEmail": "Sähköpostiosoite vastausta varten (valinnainen)", "feedbackIncludeTechnicalDetails": "Sisällytä tekniset tiedot", - "feedbackTechnicalDetailsDisclosure": "Lisää vain Linux-käyttöjärjestelmäsi version ja sovelluksen alueasetuksen. Lokeja, tilitietoja, tiedostonimiä tai muita diagnostiikkatietoja ei lisätä.", + "feedbackTechnicalDetailsDisclosure": "Lisää vain käyttöjärjestelmäsi nimen ja version sekä sovelluksen alueasetuksen. Lokeja, tilitietoja, tiedostonimiä tai muita diagnostiikkatietoja ei lisätä.", "feedbackCategoryRequired": "Valitse luokka.", "feedbackSubjectLengthError": "Aiheen pituuden on oltava 3–120 merkkiä.", "feedbackMessageLengthError": "Viestin pituuden on oltava 10–5 000 merkkiä.", @@ -180,7 +283,7 @@ "removingAccount": "Poistetaan tiliä…", "removeAccountDescription": "Lopeta synkronointi ja poista tämän tilin tiedot tältä laitteelta.", "removeAccountTitle": "Poistetaanko {account} BusyMaxista?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "Tämä poistaa laitteelta välimuistiin tallennetut tehtävät, kalenterit, tapahtumat, muistutukset ja odottavat offline-muutokset. Synkronoimattomat muutokset menetetään. Palveluntarjoajan kalenteri-, tapahtuma-, tehtäväluettelo- ja tehtäväkopioita ei poisteta.", "revokeGoogleAccess": "Peruuta myös BusyMaxin käyttöoikeus tähän Google-tiliin", "revokeGoogleAccessDescription": "Käyttöoikeus on myönnettävä uudelleen ennen tilin yhdistämistä.", "removeAccountAction": "Poista tili", @@ -196,7 +299,7 @@ "deleteList": "Poista luettelo", "builtInMicrosoftList": "Sisäänrakennettu", "builtInMicrosoftListCannotRenameDelete": "Microsoft To Do -sovelluksen sisäänrakennettuja luetteloita ei voi nimetä uudelleen tai poistaa.", - "deleteListConfirmation": "Poistetaanko \"{title}\" Google Tasksista?", + "deleteListConfirmation": "Poistetaanko ”{title}” Google Tasksista?", "deleteEvent": "Poista tapahtuma", "title": "Nimi", "create": "Luo", @@ -291,7 +394,8 @@ "synced": "Synkronoitu", "account": "Tili", "sync": "Synkronointi", - "manualFullSync": "Manuaalinen täysi synkronointi", + "forceFullResync": "Pakota täydellinen uudelleensynkronointi", + "forceFullResyncDescription": "Lataa kaikkien yhdistettyjen tilien tiedot kokonaan uudelleen. Käytä tätä vain synkronointiongelmien vianmääritykseen.", "runInBackgroundWhenClosed": "Jatka toimintaa, kun ikkuna suljetaan", "showTrayIcon": "Näytä ilmoitusalueen kuvake", "startMinimizedToTray": "Käynnistä pienennettynä ilmoitusalueelle", @@ -305,6 +409,7 @@ "notifyConflicts": "Ilmoita ristiriidoista", "notifyDueToday": "Ilmoita tänään erääntyvistä", "eventReminders": "Tapahtumamuistutukset", + "onState": "Käytössä", "taskReminders": "Tehtävämuistutukset", "notificationDetailLevel": "Ilmoitusten yksityiskohtaisuus", "notificationDetailPrivate": "Yksityinen", @@ -314,9 +419,13 @@ "quietHoursStart": "Hiljaisen ajan alku", "quietHoursEnd": "Hiljaisen ajan loppu", "notifications": "Ilmoitukset", + "windowsNotificationsUnavailable": "Windows-ilmoitukset eivät ole käytettävissä", + "windowsNotificationsUnpackaged": "Tämä paketoimaton kehitysajo ei voi käyttää Windows-ilmoituksia. Testaa muistutukset asentamalla testivarmenteella allekirjoitettu MSIX.", + "windowsNotificationsInstalledFailure": "BusyMax ei voinut alustaa Windows-ilmoituksia. Muistutuksia ei näytetä, ennen kuin tämä asennusongelma on ratkaistu.", "appearance": "Ulkoasu", "theme": "Teema", "themeSystem": "Järjestelmä", + "settingsSystem": "Järjestelmä", "themeLight": "Vaalea", "themeDark": "Tumma", "themeFamily": "Teemaperhe", @@ -361,7 +470,7 @@ "taskReminderNotificationBody": "Tehtävän määräaika lähestyy.", "notificationOpenAction": "Avaa", "notificationSnoozeAction": "Torkuta 10 minuuttia", - "notificationDismissAction": "Hylkää", + "notificationDismissAction": "Sulje", "notificationDetailsHidden": "Yksityiskohdat on piilotettu tietosuoja-asetusten vuoksi.", "previousMonth": "Edellinen kuukausi", "nextMonth": "Seuraava kuukausi", @@ -376,50 +485,50 @@ "selectTimeZone": "Valitse aikavyöhyke", "searchLocations": "Hae sijainteja", "noLocationsFound": "Sijainteja ei löytynyt", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "Tämä kenttä on pakollinen.", + "providerConnectionDescription": "Yhdistä kalenterit ja tehtävät johonkin näistä palveluntarjoajista.", + "appleICloudProvider": "Apple iCloud -kalenteri", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloud-tehtävät", + "addAppleICloudAccount": "Lisää Apple iCloud -kalenteritili", + "addNextcloudAccount": "Lisää Nextcloud-tili", + "waitingForAppleICloud": "Yhdistetään Apple iCloudiin…", + "waitingForNextcloud": "Odotetaan Nextcloud-valtuutusta…", + "connectAppleICloudTitle": "Yhdistä Apple iCloud -kalenteri", + "appleAccountEmail": "Apple-tilin sähköposti", + "appleAppSpecificPassword": "Sovelluskohtainen salasana", + "appleAppSpecificPasswordHelp": "Luo sovelluskohtainen salasana, kun olet ottanut kaksivaiheisen todennuksen käyttöön Apple-tililläsi.", + "appleAppSpecificPasswordResetWarning": "Apple-tilin salasanan vaihtaminen mitätöi sovelluskohtaiset salasanat.", + "connectNextcloudTitle": "Yhdistä Nextcloud", + "nextcloudServerUrl": "Nextcloud-palvelin tai CalDAV-osoite", + "nextcloudServerUrlHelp": "Anna Nextcloud-palvelimesi URL-osoite tai liitä Nextcloudista kopioitu ensisijainen CalDAV-osoite.", + "nextcloudBrowserAuthorizationHelp": "BusyMax avaa selaimesi. Hyväksy käyttö siellä ja palaa sitten BusyMaxiin.", + "connectAccountAction": "Yhdistä", + "cancelAccountConnection": "Peruuta yhteys", + "nextcloudAccountRemovedRevokeFailed": "Tili poistettiin paikallisesti, mutta Nextcloudin sovellussalasanaa ei voitu kumota.", + "davCachedOfflineNotice": "Kalenteri- ja tehtävätiedot tallennetaan paikallisesti offline-käyttöä varten.", + "davReauthenticationRequired": "Yhdistä tämä tili uudelleen jatkaaksesi synkronointia.", + "davTemporarilyUnavailable": "Tämä tili ei ole tilapäisesti käytettävissä.", + "davPermissionChanged": "Palvelimen käyttöoikeudet muuttuivat. Odottavat muokkaukset on keskeytetty.", + "davUnsupportedServer": "Tätä palvelinta tai palveluntarjoajan profiilia ei tueta.", + "collectionSettings": "Kalenterit ja tehtäväluettelot", + "calendarContent": "Kalenteritapahtumat", + "taskContent": "Tehtävät", + "readOnlySharedCollection": "Vain luku", + "pendingLocally": "Paikallisesti odottava", + "conflictBlocked": "Ristiriidan estämä", + "authenticationBlocked": "Estetty, kunnes yhteys muodostetaan uudelleen", + "operationFailed": "Toiminto epäonnistui", + "keepServerVersion": "Säilytä palvelinversio", + "reapplyLocalChange": "Tarkista ja ota paikallinen muutos uudelleen käyttöön", + "duplicateLocalItem": "Monista uudeksi kohteeksi", "deleteCalendarConfirmation": "Poistetaanko \"{title}\"?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "Yhteyden tila", + "davConnected": "Yhdistetty", + "davConnecting": "Yhdistetään…", + "davSignedOut": "Kirjautunut ulos", + "davLastSuccessfulSync": "Viimeisin onnistunut synkronointi: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -427,9 +536,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "Ei vielä synkronoitu", + "refreshCollections": "Päivitä kalenterit ja tehtäväluettelot", + "nextcloudServerHost": "Palvelin: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -437,12 +546,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "Tapahtumakalenteri", + "collectionSupportsTasks": "Tehtäväluettelo", + "collectionSupportsEventsAndTasks": "Tapahtumat ja tehtävät", + "writableCollection": "Muokattava", + "sharedCollection": "Jaettu", + "collectionLastSynced": "Viimeksi synkronoitu: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -450,7 +559,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "Synkronointiongelma: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -458,8 +567,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "Synkronointiristiriidat", + "remoteChangedAt": "Palvelin muuttui: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -467,7 +576,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "Paikallinen muokkaus: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -475,144 +584,366 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "conflictResolutionFailed": "Ristiriitaa ei voitu ratkaista.", + "recurringEventScope": "Toistuvan tapahtuman laajuus", + "entireSeries": "Koko sarja", + "singleOccurrence": "Tämä tapahtuma", + "thisAndFollowingEvents": "Tämä ja seuraavat tapahtumat", + "thisAndFutureUnavailable": "Tämä palveluntarjoaja ei tue tätä.", + "thisAndFutureMoveUnavailable": "Tätä ja seuraavia tapahtumia ei voi siirtää turvallisesti. Valitse tämä tapahtuma tai koko sarja.", + "entireSeriesMoveUnavailable": "Toistumissääntö ei ole saatavilla paikallisesti. Siirrä sen sijaan vain tämä tapahtuma.", + "copyEventAndDeleteOriginal": "Kopioidaanko tapahtuma ja poistetaanko alkuperäinen?", + "copyEventMoveWarning": "BusyMax ei voi siirtää tätä tapahtumaa suoraan kalenterista {source} kalenteriin {destination}. Kopio luodaan ensin, ja alkuperäinen poistetaan vasta onnistuneen kopioinnin jälkeen. Tapahtuman tunnisteet muuttuvat; osallistujien vastaustilat voivat nollautua ja kutsuja tai peruutuksia voidaan lähettää; kokouslinkit, liitteet, muistutukset, palveluntarjoajakohtaiset kentät ja toistumisen poikkeukset eivät välttämättä siirry.", + "copyAndDelete": "Kopioi ja poista", + "chooseRecurringEventScope": "Valitse, koskeeko tämä muutos koko sarjaa, vain tätä tapahtumaa vai tätä ja seuraavia tapahtumia.", "taskDueBeforeStart": "Määräaika ei saa olla ennen alkamisaikaa.", "taskStartDueTimeModeMismatch": "Aseta kellonaika sekä alkamiselle että määräajalle tai tee tehtävästä koko päivän tehtävä.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskListCreateFailed": "Tehtäväluettelon luominen epäonnistui: {error}", + "taskListRenameFailed": "Tehtäväluettelon uudelleennimeäminen epäonnistui: {error}", + "taskListDeleteFailed": "Tehtäväluettelon poistaminen epäonnistui: {error}", "unshare": "Lopeta jakaminen", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "readOnlyTaskListCannotRename": "Tämä tehtäväluettelo on vain luku -tilassa, eikä sitä voi nimetä uudelleen.", + "taskListCannotDelete": "Tätä tehtäväluetteloa ei voi poistaa nykyisillä käyttöoikeuksillasi.", + "deleteTaskListConfirmation": "Poistetaanko ”{title}” ja kaikki sen tehtävät?", + "unshareTaskListConfirmation": "Lopetetaanko kohteen ”{title}” jakaminen tämän tilin kanssa?", + "taskStatus": "Tila", + "taskStatusNone": "Ei tilaa", "taskStatusNeedsAction": "Vaatii toimenpiteitä", "taskStatusInProcess": "Käsittelyssä", - "taskStatusCompleted": "Valmiina", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusCompleted": "Valmis", + "taskStatusCancelled": "Peruutettu", + "completionPercent": "Valmis {percent} %", + "completionDate": "Valmistumispäivä", "priority": "Tärkeys", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "No reminders", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Before the task starts", - "beforeTaskDue": "Before the task is due", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "Toista joka", - "repeatOn": "Repeat on", + "priorityNone": "Ei tärkeyttä", + "priorityHighValue": "Tärkeys {priority} · korkea", + "priorityMediumValue": "Tärkeys {priority} · keskitaso", + "priorityLowValue": "Tärkeys {priority} · matala", + "taskUrl": "Tehtävän URL", + "invalidTaskUrl": "Anna absoluuttinen URL-osoite skeemoineen.", + "classification": "Luokittelu", + "classificationPublic": "Näytä koko tehtävä jaettaessa", + "classificationConfidential": "Näytä jaettaessa vain varattu-aika", + "classificationPrivate": "Piilota tämä tehtävä jaettaessa", + "pinTask": "Kiinnitä tehtävä", + "reminders": "Muistutukset", + "noReminders": "Ei muistutuksia", + "editReminder": "Muokkaa muistutusta", + "beforeTaskStarts": "Ennen tehtävän alkamista", + "beforeTaskDue": "Ennen tehtävän määräaikaa", + "afterTaskStarts": "Tehtävän alkamisen jälkeen", + "afterTaskDue": "Tehtävän määräajan jälkeen", + "relativeToTaskStart": "Suhteessa tehtävän aloituspäivään", + "relativeToTaskDue": "Suhteessa tehtävän määräpäivään", + "reminderTimeOfDay": "Kellonaika", + "absoluteReminder": "Tiettynä päivänä ja kellonaikana", + "reminderAmount": "Määrä", + "reminderUnitSeconds": "Sekunnit", + "reminderUnitMinutes": "Minuutit", + "reminderUnitHours": "Tunnit", + "reminderUnitDays": "Päivät", + "reminderUnitWeeks": "Viikot", + "reminderAtTaskStart": "Tehtävän alkaessa", + "reminderAtTaskDue": "Tehtävän määräaikana", + "unsupportedReminder": "Tämä muistutustyyppi säilytetään, mutta sen aikaa ei voi muokata.", + "relatedRemindersTitle": "Säilytetäänkö liittyvät muistutukset?", + "relatedRemindersDescription": "Tällä päivämäärällä on {count} liittyvää muistutusta. Säilytetäänkö ne nykyisessä päivämäärässä ja kellonajassa?", + "discardRelatedReminders": "Hylkää muistutukset", + "keepRelatedReminders": "Säilytä muistutukset", + "repeatEvery": "Toistoväli", + "repeatOn": "Toistopäivät", "repeatEnd": "Lopeta toisto", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", - "repeatOnMonthDaysSummary": "päivänä {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", - "repeatTimesSummary": "{count} kertaa", + "repeatNever": "Ei koskaan", + "repeatUntil": "Päivämääränä", + "repeatAfter": "Toistokertojen jälkeen", + "repeatCount": "Toistokerrat", + "repeatDayOfMonth": "Kuukauden päivät", + "repeatMonths": "Kuukaudet", + "repeatOrdinal": "Viikonpäivän järjestys", + "repeatSpecificDays": "Tietyt päivät", + "repeatFirst": "Ensimmäinen", + "repeatSecond": "Toinen", + "repeatThird": "Kolmas", + "repeatFourth": "Neljäs", + "repeatFifth": "Viides", + "repeatSecondToLast": "Toiseksi viimeinen", + "repeatLast": "Viimeinen", + "repeatAnyDay": "Päivä", + "repeatWeekday": "Arkipäivä", + "repeatWeekendDay": "Viikonlopun päivä", + "repeatOrdinalDaySummary": "{dayKey, select, MO{maanantaina} TU{tiistaina} WE{keskiviikkona} TH{torstaina} FR{perjantaina} SA{lauantaina} SU{sunnuntaina} day{päivänä} weekday{arkipäivänä} weekend{viikonlopun päivänä} other{{day}}}", + "repeatEveryDays": "Joka {count}. päivä", + "repeatEveryWeeks": "Joka {count}. viikko", + "repeatEveryMonths": "Joka {count}. kuukausi", + "repeatEveryYears": "Joka {count}. vuosi", + "repeatOnDaysSummary": "{days}", + "repeatOnMonthDaysSummary": "kuukauden {days} päivänä", + "repeatOnOrdinalSummary": "{position, select, first{ensimmäisenä {days}} second{toisena {days}} third{kolmantena {days}} fourth{neljäntenä {days}} fifth{viidentenä {days}} secondToLast{toiseksi viimeisenä {days}} last{viimeisenä {days}} other{päivinä {days}}}", + "repeatInMonthsSummary": "{months} aikana", + "repeatTimesSummary": "{count, plural, one{{count} kerran} other{{count} kertaa}}", "repeatUntilSummary": "{date} asti", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "unsupportedRecurrencePreserved": "Tämä toistosääntö käyttää asetuksia, joita tämä muokkain ei muuta.", + "recurrenceUnsupportedByProvider": "Tätä toistoa ei voi käyttää palvelussa {provider}.", + "duplicateTask": "Monista tehtävä", + "taskDuplicated": "Tehtävä monistettiin.", + "taskDuplicateFailed": "Tehtävän monistaminen epäonnistui: {error}", "hideSubtasks": "Piilota alitehtävät", "hideClosedSubtasks": "Piilota suljetut alitehtävät", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "reminderUnit": "Yksikkö", + "attendeeRequired": "Pakollinen", + "attendeeOptional": "Valinnainen", + "meetingSection": "Kokous", + "addGoogleMeet": "Lisää Google Meet", + "addTeamsMeeting": "Lisää Microsoft Teams -kokous", + "onlineMeetingAdded": "Verkkokokous lisätty", + "requestResponses": "Pyydä vastauksia", + "requestResponsesDescription": "Pyydä vieraita vastaamaan kutsuun.", + "hideGuestList": "Piilota vierasluettelo", + "hideGuestListDescription": "Vieraat eivät näe, keitä muita on kutsuttu.", + "allowNewTimeProposals": "Salli uudet aikaehdotukset", + "allowNewTimeProposalsDescription": "Vieraat voivat ehdottaa toista kokousaikaa.", + "notifyGuestsTitle": "Ilmoitetaanko vieraille?", + "notifyGuestsSaveMessage": "Tässä kokouksessa on vieraita. Lähetetäänkö kutsut tai tapahtumapäivitykset tallennettaessa?", + "notifyGuestsDeleteMessage": "Tässä kokouksessa on vieraita. Lähetetäänkö peruutus, kun kokous poistetaan?", + "sendUpdates": "Lähetä päivitykset", + "sendCancellation": "Lähetä peruutus", + "doNotSend": "Älä lähetä", + "microsoftNotifyGuestsSaveTitle": "Tallennetaanko kokous?", + "microsoftNotifyGuestsSaveMessage": "Microsoft lähettää vieraille kutsut tai tapahtumapäivitykset.", + "microsoftNotifyGuestsDeleteTitle": "Poistetaanko kokous?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft lähettää vieraille peruutuksen.", + "organizer": "Järjestäjä", + "yourResponse": "Vastauksesi", + "guestResponses": "Vieraiden vastaukset", + "respond": "Vastaa", + "acceptInvitation": "Hyväksy", + "tentativeInvitation": "Alustava", + "declineInvitation": "Hylkää", + "joinMeeting": "Liity kokoukseen", + "responseAccepted": "Hyväksytty", + "responseTentative": "Alustava", + "responseDeclined": "Hylätty", + "responseNeedsAction": "Vastausta odotetaan", + "responseNotResponded": "Ei vastausta", + "responseOrganizer": "Järjestäjä", + "invitationResponseFailed": "Vastauksesi lähetys epäonnistui: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "Kokouslinkkiä ei voitu avata.", + "setCustomCalendarName": "Aseta mukautettu nimi", + "setAction": "Aseta", + "removeFromMyCalendars": "Poista omista kalentereistani", + "removeAction": "Poista", + "removeCalendarConfirmation": "Poistetaanko ”{title}” Google Kalenterin luettelostasi? Jaettua kalenteria tai sen tapahtumia ei poisteta.", + "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.", + "calendarSubscriptions": "Kalenteritilaukset", + "calendarSubscriptionsDescription": "Lisää vain luku -kalentereita, jotka päivittyvät suojatusta WebCal-URL-osoitteesta.", + "addCalendarSubscription": "Lisää kalenteritilaus", + "subscriptionName": "Paikallinen nimi", + "subscriptionUrl": "Tilauksen URL", + "subscriptionUrlHelp": "Anna HTTPS- tai webcal-URL. BusyMax säilyttää täydellisen URL-osoitteen suojatussa tallennustilassa.", + "subscriptionUrlInvalid": "Anna kelvollinen HTTPS- tai webcal-URL ilman käyttäjätietoja tai fragmenttia.", + "subscriptionColor": "Paikallinen väri", + "subscriptionColorHelp": "Käytä kuusinumeroista väriä, kuten #3584E4.", + "subscriptionColorInvalid": "Anna kuusinumeroinen heksadesimaaliväri.", + "subscriptionRefreshMode": "Päivitystiheys", + "subscriptionAutomatic": "Automaattinen", + "subscriptionHourly": "Tunneittain", + "subscriptionSixHours": "Kuuden tunnin välein", + "subscriptionDaily": "Päivittäin", + "subscriptionSafeOrigin": "Lähde: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Anna kelvollinen URL esikatsellaksesi sen suojattua alkuperää.", + "subscriptionReadOnly": "Vain luku -tilaus", + "subscriptionNeverRefreshed": "Ei vielä päivitetty", + "subscriptionLastRefresh": "Viimeisin onnistunut päivitys: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Seuraava päivitys: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Ajan tasalla", + "subscriptionStatusIssue": "Päivitysongelma: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Päivitä nyt", + "unsubscribe": "Peruuta tilaus", + "unsubscribeCalendarTitle": "Perutaanko tilaus kalenterista ”{name}”?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "Tämä poistaa paikallisen tilauksen ja sen välimuistissa olevat tapahtumat. Julkaistua kalenteria ei muuteta.", + "addSubscriptionAction": "Lisää tilaus", + "subscriptionOperationFailed": "Kalenteritilaus epäonnistui: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Tilaukset", + "calendarImport": "Kalenterin tuonti", + "calendarImportDescription": "Valitse tiedosto, tarkista sen tapahtumat ja valitse sitten muokattava kalenteri, joka vastaanottaa ne.", + "importIcsFile": "Tuo .ics-tiedosto", + "importIcsPreview": "Tuo kalenteritapahtumat", + "importEventsFound": "Tuotavat tapahtumajoukot: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Virheelliset tapahtumat: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Jätetty tarkoituksella pois: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "Muokattavaa kohdekalenteria ei ole saatavilla.", + "importDestinationCalendar": "Kohdekalenteri", + "importIcsConfirm": "Tuo tapahtumat", + "importIcsComplete": "Tuonti valmis", + "importQueued": "Tuotu tai jonossa: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Ohitetut kaksoiskappaleet: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Tukemattomat toistojoukot: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "Kalenteritiedostoa ei voitu tuoda: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "Ei verkkoyhteyttä", "networkOfflineDescription": "Muutokset synkronoidaan, kun yhteys palautuu.", - "networkOfflineTryAgain": "Verkkoyhteyttä ei ole. Yhdistä internetiin ja yritä uudelleen." + "networkOfflineTryAgain": "Verkkoyhteyttä ei ole. Yhdistä internetiin ja yritä uudelleen.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "kuukauden {days} päivänä", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}.", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first} ja {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} ja {second}", + "repeatMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthValue": "{monthKey, select, jan{tammikuun} feb{helmikuun} mar{maaliskuun} apr{huhtikuun} may{toukokuun} jun{kesäkuun} jul{heinäkuun} aug{elokuun} sep{syyskuun} oct{lokakuun} nov{marraskuun} dec{joulukuun} other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} ja {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} ja {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} {month} {day} päivänä", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} {months} {day} päivänä", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} {months} {days} päivänä", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {months} {position, select, first{ensimmäisenä} second{toisena} third{kolmantena} fourth{neljäntenä} fifth{viidentenä} secondToLast{toiseksi viimeisenä} last{viimeisenä} other{}} {days}", + "repeatYearlyOnMonthDaysSummary": "{frequency} {month} {days} päivänä", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {month} {position, select, first{ensimmäisenä} second{toisena} third{kolmantena} fourth{neljäntenä} fifth{viidentenä} secondToLast{toiseksi viimeisenä} last{viimeisenä} other{}} {days}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0474e8d2..120ae93d 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,14 +1,31 @@ { "@@locale": "fr", + "windowsSupport": "Assistance", + "windowsThirdPartyLicenses": "Licences tierces", + "windowsSearch": "Rechercher", + "windowsStartupDisabledByUser": "Désactivé par l’utilisateur dans les paramètres Windows.", + "windowsStartupDisabledByPolicy": "Désactivé par une stratégie Windows.", + "windowsStartupUnavailable": "Disponible après l’installation de BusyMax à partir d’un package MSIX.", + "windowsReminderExitNotice": "Les rappels s’arrêtent lorsque BusyMax est complètement fermé. Laissez-le s’exécuter en arrière-plan pour les recevoir.", + "windowsProductVersionLabel": "Version du produit", + "windowsPackageVersionLabel": "Version du package Windows", + "windowsUnpackaged": "Non empaqueté", + "windowsAgendaLoadMore": "Charger plus d’éléments de l’agenda", + "repeatWeeklyDaySummary": "{dayKey, select, MO{le lundi} TU{le mardi} WE{le mercredi} TH{le jeudi} FR{le vendredi} SA{le samedi} SU{le dimanche} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "les jours {first} et {second} du mois", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} les jours {firstDay} et {secondDay} de {month}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} le {day} de {firstMonth} et {secondMonth}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} les jours {firstDay} et {secondDay} de {firstMonth} et {secondMonth}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} les jours {days} de {firstMonth} et {secondMonth}", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Connectez les comptes Google, Microsoft, Calendrier Apple iCloud ou Nextcloud.", "googlePermissionsConsentNotice": "Sur l’écran d’autorisations Google, sélectionnez les autorisations Calendrier et Tâches.", "googlePermissionsRequiredRetry": "Les autorisations Google Calendar et Google Tasks sont requises. Réessayez et sélectionnez les deux cases.", "finishSetup": "Terminer la configuration", "continueSetup": "Continuer", "onboardingSetupTitle": "Configurer BusyMax", "onboardingAccountsStepTitle": "Connecter des comptes", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "Ajoutez tous les comptes que vous souhaitez utiliser. BusyMax synchronise les calendriers, événements, listes de tâches et tâches pris en charge de chaque compte.", "onboardingPreferencesStepTitle": "Choisir les paramètres système", "onboardingPreferencesStepDescription": "Réglez le comportement de l’application sur le bureau, les rappels, le niveau de détail des notifications et l’apparence avant d’ouvrir votre planning.", "signInWithGoogle": "Se connecter avec Google", @@ -22,7 +39,7 @@ "cancel": "Annuler", "close": "Fermer", "exit": "Quitter", - "options": "Options", + "options": "Choix", "hide": "Masquer", "show": "Afficher", "export": "Exporter", @@ -33,6 +50,43 @@ "calendarTasks": "Tâches", "calendar": "Calendrier", "calendars": "Calendriers", + "newCalendar": "Nouveau calendrier", + "calendarColor": "Couleur du calendrier", + "calendarColorOption": "Couleur {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "Ce fournisseur ne prend pas en charge la gestion des calendriers dans BusyMax.", + "primaryCalendarCannotDelete": "Le calendrier principal ne peut pas être supprimé.", + "calendarCreateFailed": "Impossible de créer le calendrier : {error}", + "calendarCreatedRefreshPending": "Le calendrier a été créé, mais BusyMax n’a pas pu actualiser le compte. Il apparaîtra après la prochaine synchronisation.", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "Impossible de mettre à jour le calendrier : {error}", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "Impossible de supprimer le calendrier : {error}", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "Nouvel événement", "refreshCalendar": "Actualiser le calendrier", "openInProvider": "Ouvrir dans le service", @@ -52,13 +106,62 @@ "scheduleNoSearchResultsDescription": "Essayez une autre recherche ou effacez les filtres actuels.", "refresh": "Actualiser", "trayOpenBusyMax": "Ouvrir BusyMax", + "trayShowBusyMax": "Afficher BusyMax", + "trayNewEvent": "Nouvel événement…", + "trayNewTask": "Nouvelle tâche…", + "trayToday": "Aujourd’hui", + "trayAllDay": "Toute la journée", + "trayNow": "Maintenant", + "trayCalendarEvent": "Événement du calendrier", + "trayUntitledEvent": "Événement sans titre", + "trayNothingElseToday": "Rien d’autre aujourd’hui", + "trayTasksDueToday": "{count, plural, =1{1 tâche arrive à échéance aujourd’hui} other{{count} tâches arrivent à échéance aujourd’hui}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Ouvrir l’agenda du jour", + "traySyncNow": "Synchroniser maintenant", + "traySyncing": "Synchronisation…", + "trayNotConnected": "Non connecté", + "trayNotYetSynced": "Pas encore synchronisé", + "trayLastSyncedJustNow": "Synchronisé à l’instant", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Synchronisé il y a 1 minute} other{Synchronisé il y a {count} minutes}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Synchronisé il y a 1 heure} other{Synchronisé il y a {count} heures}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Synchronisé il y a 1 jour} other{Synchronisé il y a {count} jours}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Paramètres", + "trayQuitBusyMax": "Quitter BusyMax", "agendaLoadMoreOverdue": "Charger plus de tâches en retard", "agendaLoadMoreNoDate": "Charger plus de tâches sans date", "viewDay": "Jour", "viewWeek": "Semaine", "viewMonth": "Mois", "viewYear": "Année", - "viewAgenda": "Agenda", + "viewAgenda": "Vue agenda", "scheduleSettings": "Planning", "scheduleDisplaySettings": "Affichage du planning", "scheduleDisplayHoursDescription": "Les vues Jour et Semaine s’ouvrent dans cette plage horaire. Les éléments situés avant ou après cette plage l’étendent si nécessaire.", @@ -79,7 +182,7 @@ "defaultReminder": "Rappel par défaut", "guests": "Invités", "noGuests": "Aucun invité", - "description": "Description", + "description": "Détails", "availabilityShowAs": "Disponibilité / Afficher comme", "busy": "Occupé", "visibility": "Visibilité", @@ -132,12 +235,12 @@ "allTasks": "Toutes les tâches", "tasksInList": "Tâches dans {title}", "taskLists": "Listes de tâches", - "navigation": "Navigation", + "navigation": "Déplacement", "mainMenu": "Menu principal", "keyboardShortcuts": "Raccourcis clavier", "shortcutGroupGeneral": "Général", "shortcutKeyboardShortcutsDescription": "Afficher cette liste de raccourcis", - "shortcutGroupNavigation": "Navigation", + "shortcutGroupNavigation": "Déplacement", "shortcutNextPeriod": "Période suivante", "shortcutNextPeriodDescription": "Semaine suivante en vue semaine, mois suivant en vue mois, etc.", "shortcutPreviousPeriod": "Période précédente", @@ -175,17 +278,17 @@ "feedbackDetailedMessage": "Message détaillé", "feedbackReplyEmail": "Adresse e-mail de réponse (facultatif)", "feedbackIncludeTechnicalDetails": "Inclure les détails techniques", - "feedbackTechnicalDetailsDisclosure": "Ajoute uniquement la version de votre système d’exploitation Linux et la langue de l’application. Aucun journal, aucune donnée de compte, aucun nom de fichier ni autre diagnostic n’est inclus.", + "feedbackTechnicalDetailsDisclosure": "Ajoute uniquement le nom et la version de votre système d’exploitation et la langue de l’application. Aucun journal, aucune donnée de compte, aucun nom de fichier ni autre diagnostic n’est inclus.", "feedbackCategoryRequired": "Sélectionnez une catégorie.", "feedbackSubjectLengthError": "L’objet doit comporter entre 3 et 120 caractères.", "feedbackMessageLengthError": "Le message doit comporter entre 10 et 5 000 caractères.", "feedbackInvalidEmail": "Saisissez une adresse e-mail valide.", "feedbackConnectionError": "Impossible de se connecter à BusyStack. Vérifiez votre connexion et réessayez.", - "feedbackTimeoutError": "La demande a expiré. Vos commentaires n’ont pas été effacés ; réessayez.", + "feedbackTimeoutError": "La demande a expiré. Vos commentaires n’ont pas été effacés ; réessayez.", "feedbackRateLimitedError": "Trop de commentaires ont été envoyés depuis ce réseau. Patientez, puis réessayez.", "feedbackRejectedError": "Le serveur a rejeté l’envoi. Vérifiez les champs, puis réessayez.", - "feedbackServerError": "BusyStack ne peut pas accepter vos commentaires pour le moment. Vos commentaires n’ont pas été effacés ; réessayez.", - "feedbackSuccess": "Commentaires envoyés. Référence : {id}", + "feedbackServerError": "BusyStack ne peut pas accepter vos commentaires pour le moment. Vos commentaires n’ont pas été effacés ; réessayez.", + "feedbackSuccess": "Commentaires envoyés. Référence : {id}", "toggleSidebar": "Afficher/masquer la barre latérale", "showSidebar": "Afficher le panneau latéral", "hideSidebar": "Masquer le panneau latéral", @@ -208,7 +311,7 @@ } } }, - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "Cette action supprime de cet appareil les tâches, calendriers, événements, rappels et modifications hors ligne en attente mis en cache. Les modifications non synchronisées seront perdues. Les copies des calendriers, événements, listes de tâches et tâches conservées chez le fournisseur ne sont pas supprimées.", "revokeGoogleAccess": "Révoquer également l’accès de BusyMax à ce compte Google", "revokeGoogleAccessDescription": "Vous devrez accorder à nouveau l’accès avant de reconnecter le compte.", "removeAccountAction": "Supprimer le compte", @@ -224,7 +327,7 @@ "deleteList": "Supprimer la liste", "builtInMicrosoftList": "Intégrée", "builtInMicrosoftListCannotRenameDelete": "Les listes Microsoft To Do intégrées ne peuvent pas être renommées ni supprimées.", - "deleteListConfirmation": "Supprimer « {title} » de Google Tasks ?", + "deleteListConfirmation": "Supprimer « {title} » de Google Tasks ?", "deleteEvent": "Supprimer l’événement", "title": "Titre", "create": "Créer", @@ -242,7 +345,7 @@ } } }, - "exportFailed": "Échec de l’export : {error}", + "exportFailed": "Échec de l’export : {error}", "@exportFailed": { "placeholders": { "error": { @@ -250,7 +353,7 @@ } } }, - "refreshFailed": "Échec de l’actualisation : {error}", + "refreshFailed": "Échec de l’actualisation : {error}", "selectOrCreateTaskList": "Sélectionnez ou créez une liste de tâches pour commencer.", "signInToViewTasks": "Connectez-vous pour voir les tâches.", "noTasks": "Aucune tâche.", @@ -276,7 +379,7 @@ "statusSection": "État", "openStatus": "Ouverte", "doneStatus": "Terminée", - "notes": "Notes", + "notes": "Remarques", "dueDate": "Date d’échéance", "clearDueDate": "Effacer la date d’échéance", "dueTime": "Heure d’échéance", @@ -299,7 +402,7 @@ "repeatWeekly": "Hebdomadaire", "repeatMonthly": "Mensuel", "repeatYearly": "Annuel", - "importance": "Importance", + "importance": "Niveau d’importance", "importanceLow": "Faible", "importanceNormal": "Normale", "importanceHigh": "Élevée", @@ -309,7 +412,7 @@ "startGroup": "Début", "reminderGroup": "Rappel", "organizationSection": "Organisation", - "actionsSection": "Actions", + "actionsSection": "Opérations", "advancedSection": "Avancé", "addCategory": "Ajouter une catégorie", "list": "Liste", @@ -319,13 +422,13 @@ "moveToTop": "Déplacer tout en haut", "deleteTask": "Supprimer la tâche", "newSubtask": "Nouvelle sous-tâche", - "deleteTaskConfirmation": "Supprimer « {title} » ?", + "deleteTaskConfirmation": "Supprimer « {title} » ?", "metadata": "Métadonnées", "id": "ID", "etag": "ETag", "updated": "Mis à jour", "parent": "Tâche parente", - "position": "Position", + "position": "Ordre", "webLink": "Lien web", "assignment": "Attribution", "localState": "État local", @@ -333,7 +436,8 @@ "synced": "Synchronisé", "account": "Compte", "sync": "Synchronisation", - "manualFullSync": "Synchronisation complète manuelle", + "forceFullResync": "Forcer une resynchronisation complète", + "forceFullResyncDescription": "Recharge entièrement les données de chaque compte connecté. N’utilisez cette option que pour résoudre des problèmes de synchronisation.", "runInBackgroundWhenClosed": "Continuer à s’exécuter après la fermeture de la fenêtre", "showTrayIcon": "Afficher l’icône dans la zone de notification", "startMinimizedToTray": "Démarrer réduit dans la zone de notification", @@ -342,23 +446,28 @@ "launchAtLoginFailed": "Impossible de modifier le lancement à la connexion.", "requiresTrayIcon": "Nécessite l’icône de la zone de notification.", "syncComplete": "Synchronisation terminée.", - "syncFailed": "Échec de la synchronisation : {error}", + "syncFailed": "Échec de la synchronisation : {error}", "notifySyncFailures": "Notifications d’échec de synchronisation", "notifyConflicts": "Notifications de conflits", "notifyDueToday": "Notifications des tâches dues aujourd’hui", "eventReminders": "Rappels d’événements", + "onState": "Activé", "taskReminders": "Rappels de tâches", "notificationDetailLevel": "Niveau de détail des notifications", "notificationDetailPrivate": "Privé", - "notificationDetailNormal": "Normal", + "notificationDetailNormal": "Standard", "quietHours": "Période de silence", "quietHoursDescription": "Mettre les notifications en pause pendant cette période.", "quietHoursStart": "Début de la période de silence", "quietHoursEnd": "Fin de la période de silence", - "notifications": "Notifications", + "notifications": "Alertes", + "windowsNotificationsUnavailable": "Les notifications Windows sont indisponibles", + "windowsNotificationsUnpackaged": "Cette exécution de développement non empaquetée ne peut pas utiliser les notifications Windows. Installez le MSIX signé pour les tests afin de vérifier les rappels.", + "windowsNotificationsInstalledFailure": "BusyMax n’a pas pu initialiser les notifications Windows. Les rappels ne s’afficheront pas tant que ce problème d’installation ne sera pas résolu.", "appearance": "Apparence", "theme": "Thème", "themeSystem": "Système", + "settingsSystem": "Système", "themeLight": "Clair", "themeDark": "Sombre", "themeFamily": "Famille de thèmes", @@ -368,10 +477,10 @@ "privacy": "Confidentialité", "redactTaskContentInDiagnostics": "Masquer le contenu des tâches dans les diagnostics", "developerDiagnostics": "Diagnostics développeur", - "diagnostics": "Diagnostics", + "diagnostics": "Informations de diagnostic", "apiInspectorDisabled": "Afficher l’inspecteur API", "googleTasksApi": "API Google Tasks", - "discoveryRevision": "Révision Discovery : {revision}", + "discoveryRevision": "Révision Discovery : {revision}", "implementedMethods": "Méthodes implémentées", "supportsTasksScopes": "Prend en charge les champs d’application tasks et tasks.readonly", "requiresTasksScope": "Nécessite le champ d’application tasks", @@ -385,10 +494,10 @@ "retry": "Réessayer", "discard": "Abandonner", "discardChangesAction": "Abandonner", - "discardChanges": "Abandonner les modifications ?", + "discardChanges": "Abandonner les modifications ?", "discardChangesConfirmation": "Les modifications non enregistrées apportées à cette tâche seront perdues.", "retryCompleted": "Nouvelle tentative terminée.", - "discardPendingOperation": "Abandonner l’opération en attente ?", + "discardPendingOperation": "Abandonner l’opération en attente ?", "discardPendingOperationConfirmation": "Cette action supprime l’opération locale bloquée. Lors de la prochaine synchronisation, les données seront rechargées depuis Google Tasks.", "pendingOperationDiscarded": "Opération en attente abandonnée.", "syncFailureNotificationTitle": "Échec de la synchronisation BusyMax", @@ -403,7 +512,7 @@ "taskReminderNotificationBody": "La tâche arrive bientôt à échéance.", "notificationOpenAction": "Ouvrir", "notificationSnoozeAction": "Rappeler dans 10 minutes", - "notificationDismissAction": "Ignorer", + "notificationDismissAction": "Fermer", "notificationDetailsHidden": "Les détails sont masqués par les paramètres de confidentialité.", "previousMonth": "Mois précédent", "nextMonth": "Mois suivant", @@ -432,45 +541,45 @@ "selectTimeZone": "Sélectionner le fuseau horaire", "searchLocations": "Rechercher des lieux", "noLocationsFound": "Aucun lieu trouvé", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "Ce champ est obligatoire.", + "providerConnectionDescription": "Connectez les calendriers et les tâches à l’un de ces fournisseurs.", + "appleICloudProvider": "Calendrier Apple iCloud", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", - "deleteCalendarConfirmation": "Supprimer « {title} » ?", + "nextcloudTasksProvider": "Tâches Nextcloud", + "addAppleICloudAccount": "Ajouter un compte Calendrier Apple iCloud", + "addNextcloudAccount": "Ajouter un compte Nextcloud", + "waitingForAppleICloud": "Connexion à Apple iCloud…", + "waitingForNextcloud": "En attente de l’autorisation Nextcloud…", + "connectAppleICloudTitle": "Connecter le Calendrier Apple iCloud", + "appleAccountEmail": "E-mail du compte Apple", + "appleAppSpecificPassword": "Mot de passe spécifique à l’app", + "appleAppSpecificPasswordHelp": "Créez un mot de passe spécifique à l’app après avoir activé l’authentification à deux facteurs de votre compte Apple.", + "appleAppSpecificPasswordResetWarning": "La réinitialisation du mot de passe de votre compte Apple révoque les mots de passe spécifiques aux apps.", + "connectNextcloudTitle": "Connecter Nextcloud", + "nextcloudServerUrl": "Serveur Nextcloud ou adresse CalDAV", + "nextcloudServerUrlHelp": "Saisissez l’URL de votre serveur Nextcloud ou collez l’adresse CalDAV principale copiée depuis Nextcloud.", + "nextcloudBrowserAuthorizationHelp": "BusyMax va ouvrir votre navigateur. Autorisez-y l’accès, puis revenez dans BusyMax.", + "connectAccountAction": "Connecter", + "cancelAccountConnection": "Annuler la connexion", + "nextcloudAccountRemovedRevokeFailed": "Le compte a été supprimé localement, mais le mot de passe d’app Nextcloud n’a pas pu être révoqué.", + "davCachedOfflineNotice": "Les données des calendriers et des tâches sont mises en cache localement pour une utilisation hors connexion.", + "davReauthenticationRequired": "Reconnectez ce compte pour reprendre la synchronisation.", + "davTemporarilyUnavailable": "Ce compte est temporairement indisponible.", + "davPermissionChanged": "Les autorisations du serveur ont changé. Les modifications en attente sont suspendues.", + "davUnsupportedServer": "Ce serveur ou profil de fournisseur n’est pas pris en charge.", + "collectionSettings": "Calendriers et listes de tâches", + "calendarContent": "Événements du calendrier", + "taskContent": "Tâches", + "readOnlySharedCollection": "Lecture seule", + "pendingLocally": "En attente localement", + "conflictBlocked": "Bloqué par un conflit", + "authenticationBlocked": "Bloqué jusqu’à la reconnexion", + "operationFailed": "Échec de l’opération", + "keepServerVersion": "Conserver la version du serveur", + "reapplyLocalChange": "Examiner et réappliquer la modification locale", + "duplicateLocalItem": "Dupliquer comme nouvel élément", + "deleteCalendarConfirmation": "Supprimer « {title} » ?", "@deleteCalendarConfirmation": { "placeholders": { "title": { @@ -478,11 +587,11 @@ } } }, - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "État de la connexion", + "davConnected": "Connecté", + "davConnecting": "Connexion…", + "davSignedOut": "Déconnecté", + "davLastSuccessfulSync": "Dernière synchronisation réussie : {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -490,9 +599,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "Pas encore synchronisé", + "refreshCollections": "Actualiser les calendriers et les listes de tâches", + "nextcloudServerHost": "Serveur : {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -500,12 +609,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "Calendrier d’événements", + "collectionSupportsTasks": "Liste de tâches", + "collectionSupportsEventsAndTasks": "Événements et tâches", + "writableCollection": "Modifiable", + "sharedCollection": "Partagé", + "collectionLastSynced": "Dernière synchronisation : {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -513,7 +622,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "Problème de synchronisation : {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -521,8 +630,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "Conflits de synchronisation", + "remoteChangedAt": "Modification du serveur : {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -530,7 +639,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "Modification locale : {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -538,144 +647,366 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "conflictResolutionFailed": "Impossible de résoudre le conflit.", + "recurringEventScope": "Portée de l’événement récurrent", + "entireSeries": "Série entière", + "singleOccurrence": "Cet événement", + "thisAndFollowingEvents": "Cet événement et les suivants", + "thisAndFutureUnavailable": "Non pris en charge par ce fournisseur.", + "thisAndFutureMoveUnavailable": "Cet événement et les suivants ne peuvent pas être déplacés de manière fiable. Choisissez cet événement ou toute la série.", + "entireSeriesMoveUnavailable": "La règle de récurrence n’est pas disponible localement. Déplacez uniquement cet événement.", + "copyEventAndDeleteOriginal": "Copier l’événement et supprimer l’original ?", + "copyEventMoveWarning": "BusyMax ne peut pas déplacer directement cet événement de {source} vers {destination}. La copie sera créée en premier et l’original ne sera supprimé qu’après la réussite de la copie. Les identifiants changeront ; les statuts de réponse des participants pourront être réinitialisés et des invitations ou annulations envoyées ; les liens de conférence, pièces jointes, rappels, champs propres au fournisseur et exceptions de récurrence pourront ne pas être conservés.", + "copyAndDelete": "Copier et supprimer", + "chooseRecurringEventScope": "Choisissez si cette modification s’applique à toute la série, à cet événement uniquement ou à cet événement et aux suivants.", "taskDueBeforeStart": "L’échéance ne peut pas précéder le début.", "taskStartDueTimeModeMismatch": "Définissez une heure pour le début et l’échéance, ou passez la tâche en journée entière.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskListCreateFailed": "Impossible de créer la liste de tâches : {error}", + "taskListRenameFailed": "Impossible de renommer la liste de tâches : {error}", + "taskListDeleteFailed": "Impossible de supprimer la liste de tâches : {error}", "unshare": "Ne plus partager", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", - "taskStatusNeedsAction": "Nécessite une action", + "readOnlyTaskListCannotRename": "Cette liste de tâches est en lecture seule et ne peut pas être renommée.", + "taskListCannotDelete": "Cette liste de tâches ne peut pas être supprimée avec vos autorisations actuelles.", + "deleteTaskListConfirmation": "Supprimer « {title} » et toutes ses tâches ?", + "unshareTaskListConfirmation": "Ne plus partager « {title} » avec ce compte ?", + "taskStatus": "État", + "taskStatusNone": "Aucun état", + "taskStatusNeedsAction": "Action requise", "taskStatusInProcess": "En cours", - "taskStatusCompleted": "Terminé", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusCompleted": "Terminée", + "taskStatusCancelled": "Annulée", + "completionPercent": "{percent} % terminée", + "completionDate": "Date d’achèvement", "priority": "Priorité", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", + "priorityNone": "Aucune priorité", + "priorityHighValue": "Priorité {priority} · élevée", + "priorityMediumValue": "Priorité {priority} · moyenne", + "priorityLowValue": "Priorité {priority} · faible", + "taskUrl": "URL de la tâche", + "invalidTaskUrl": "Saisissez une URL absolue, avec son schéma.", + "classification": "Catégorisation", + "classificationPublic": "Lorsqu’elle est partagée, afficher la tâche complète", + "classificationConfidential": "Lorsqu’elle est partagée, afficher uniquement l’état occupé", + "classificationPrivate": "Lorsqu’elle est partagée, masquer cette tâche", + "pinTask": "Épingler la tâche", + "reminders": "Rappels", "noReminders": "Aucun rappel", - "editReminder": "Edit reminder", + "editReminder": "Modifier le rappel", "beforeTaskStarts": "Avant le début de la tâche", - "beforeTaskDue": "Avant l'échéance de la tâche", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "Répéter chaque", - "repeatOn": "Repeat on", - "repeatEnd": "Arrêter la répétition", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", - "repeatOnMonthDaysSummary": "le {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", - "repeatTimesSummary": "{count} fois", - "repeatUntilSummary": "jusqu'au {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "beforeTaskDue": "Avant l’échéance de la tâche", + "afterTaskStarts": "Après le début de la tâche", + "afterTaskDue": "Après l’échéance de la tâche", + "relativeToTaskStart": "Par rapport à la date de début de la tâche", + "relativeToTaskDue": "Par rapport à la date d’échéance de la tâche", + "reminderTimeOfDay": "Heure de la journée", + "absoluteReminder": "À une date et une heure", + "reminderAmount": "Quantité", + "reminderUnitSeconds": "Secondes", + "reminderUnitMinutes": "min", + "reminderUnitHours": "Heures", + "reminderUnitDays": "Jours", + "reminderUnitWeeks": "Semaines", + "reminderAtTaskStart": "Au début de la tâche", + "reminderAtTaskDue": "À l’échéance de la tâche", + "unsupportedReminder": "Ce type de rappel est conservé, mais son heure ne peut pas être modifiée.", + "relatedRemindersTitle": "Conserver les rappels associés ?", + "relatedRemindersDescription": "Cette date comporte {count} rappels associés. Les conserver à leur date et heure actuelles ?", + "discardRelatedReminders": "Supprimer les rappels", + "keepRelatedReminders": "Conserver les rappels", + "repeatEvery": "Intervalle", + "repeatOn": "Répéter le", + "repeatEnd": "Terminer la répétition", + "repeatNever": "Jamais", + "repeatUntil": "À une date", + "repeatAfter": "Après un certain nombre de répétitions", + "repeatCount": "Nombre d’occurrences", + "repeatDayOfMonth": "Jours du mois", + "repeatMonths": "Mois", + "repeatOrdinal": "Position du jour de la semaine", + "repeatSpecificDays": "Jours précis", + "repeatFirst": "Premier", + "repeatSecond": "Deuxième", + "repeatThird": "Troisième", + "repeatFourth": "Quatrième", + "repeatFifth": "Cinquième", + "repeatSecondToLast": "Avant-dernier", + "repeatLast": "Dernier", + "repeatAnyDay": "Jour", + "repeatWeekday": "Jour de semaine", + "repeatWeekendDay": "Jour du week-end", + "repeatOrdinalDaySummary": "{dayKey, select, MO{lundi} TU{mardi} WE{mercredi} TH{jeudi} FR{vendredi} SA{samedi} SU{dimanche} day{jour} weekday{jour de semaine} weekend{jour du week-end} other{{day}}}", + "repeatEveryDays": "Tous les {count} jours", + "repeatEveryWeeks": "Toutes les {count} semaines", + "repeatEveryMonths": "Tous les {count} mois", + "repeatEveryYears": "Toutes les {count} années", + "repeatOnDaysSummary": "{days}", + "repeatOnMonthDaysSummary": "le {days} du mois", + "repeatOnOrdinalSummary": "{position, select, first{le premier {days}} second{le deuxième {days}} third{le troisième {days}} fourth{le quatrième {days}} fifth{le cinquième {days}} secondToLast{l’avant-dernier {days}} last{le dernier {days}} other{les {days}}}", + "repeatInMonthsSummary": "en {months}", + "repeatTimesSummary": "{count, plural, one{{count} fois} other{{count} fois}}", + "repeatUntilSummary": "jusqu’au {date}", + "unsupportedRecurrencePreserved": "Cette règle de récurrence utilise des options que cet éditeur ne modifie pas.", + "recurrenceUnsupportedByProvider": "Cette récurrence ne peut pas être utilisée avec {provider}.", + "duplicateTask": "Dupliquer la tâche", + "taskDuplicated": "Tâche dupliquée.", + "taskDuplicateFailed": "Impossible de dupliquer la tâche : {error}", "hideSubtasks": "Masquer les sous-tâches", - "hideClosedSubtasks": "Masquer les sous-tâches fermées", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "hideClosedSubtasks": "Masquer les sous-tâches terminées", + "reminderUnit": "Unité", + "attendeeRequired": "Obligatoire", + "attendeeOptional": "Facultatif", + "meetingSection": "Réunion", + "addGoogleMeet": "Ajouter Google Meet", + "addTeamsMeeting": "Ajouter une réunion Microsoft Teams", + "onlineMeetingAdded": "Réunion en ligne ajoutée", + "requestResponses": "Demander des réponses", + "requestResponsesDescription": "Demandez aux invités de répondre à l’invitation.", + "hideGuestList": "Masquer la liste des invités", + "hideGuestListDescription": "Les invités ne peuvent pas voir qui d’autre a été invité.", + "allowNewTimeProposals": "Autoriser les nouvelles propositions d’horaire", + "allowNewTimeProposalsDescription": "Les invités peuvent proposer une autre heure de réunion.", + "notifyGuestsTitle": "Notifier les invités ?", + "notifyGuestsSaveMessage": "Cette réunion comporte des invités. Envoyer les invitations ou les mises à jour de l’événement lors de l’enregistrement ?", + "notifyGuestsDeleteMessage": "Cette réunion comporte des invités. Envoyer une annulation lors de sa suppression ?", + "sendUpdates": "Envoyer les mises à jour", + "sendCancellation": "Envoyer l’annulation", + "doNotSend": "Ne pas envoyer", + "microsoftNotifyGuestsSaveTitle": "Enregistrer la réunion ?", + "microsoftNotifyGuestsSaveMessage": "Microsoft enverra les invitations ou les mises à jour de l’événement aux invités.", + "microsoftNotifyGuestsDeleteTitle": "Supprimer la réunion ?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft enverra une annulation aux invités.", + "organizer": "Organisateur", + "yourResponse": "Votre réponse", + "guestResponses": "Réponses des invités", + "respond": "Répondre", + "acceptInvitation": "Accepter", + "tentativeInvitation": "Provisoire", + "declineInvitation": "Refuser", + "joinMeeting": "Rejoindre la réunion", + "responseAccepted": "Acceptée", + "responseTentative": "Provisoire", + "responseDeclined": "Refusée", + "responseNeedsAction": "Réponse attendue", + "responseNotResponded": "Sans réponse", + "responseOrganizer": "Organisateur", + "invitationResponseFailed": "Impossible d’envoyer votre réponse : {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "Impossible d’ouvrir le lien de la réunion.", + "setCustomCalendarName": "Définir un nom personnalisé", + "setAction": "Définir", + "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.", + "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.", + "calendarSubscriptions": "Abonnements aux calendriers", + "calendarSubscriptionsDescription": "Ajoutez des calendriers en lecture seule qui se mettent à jour depuis une URL WebCal sécurisée.", + "addCalendarSubscription": "Ajouter un abonnement de calendrier", + "subscriptionName": "Nom local", + "subscriptionUrl": "URL de l’abonnement", + "subscriptionUrlHelp": "Saisissez une URL HTTPS ou webcal. BusyMax conserve l’URL complète dans un stockage sécurisé.", + "subscriptionUrlInvalid": "Saisissez une URL HTTPS ou webcal valide, sans informations utilisateur ni fragment.", + "subscriptionColor": "Couleur locale", + "subscriptionColorHelp": "Utilisez une couleur à six chiffres, comme #3584E4.", + "subscriptionColorInvalid": "Saisissez une couleur hexadécimale à six chiffres.", + "subscriptionRefreshMode": "Fréquence d’actualisation", + "subscriptionAutomatic": "Automatique", + "subscriptionHourly": "Toutes les heures", + "subscriptionSixHours": "Toutes les six heures", + "subscriptionDaily": "Tous les jours", + "subscriptionSafeOrigin": "Source : {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Saisissez une URL valide pour prévisualiser son origine sécurisée.", + "subscriptionReadOnly": "Abonnement en lecture seule", + "subscriptionNeverRefreshed": "Pas encore actualisé", + "subscriptionLastRefresh": "Dernière actualisation réussie : {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Prochaine actualisation : {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "À jour", + "subscriptionStatusIssue": "Problème d’actualisation : {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Actualiser maintenant", + "unsubscribe": "Se désabonner", + "unsubscribeCalendarTitle": "Se désabonner de « {name} » ?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "Cette action supprime l’abonnement local et ses événements mis en cache. Le calendrier publié n’est pas modifié.", + "addSubscriptionAction": "Ajouter un abonnement", + "subscriptionOperationFailed": "Échec de l’abonnement au calendrier : {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Abonnements", + "calendarImport": "Importation de calendrier", + "calendarImportDescription": "Sélectionnez un fichier, vérifiez ses événements, puis choisissez le calendrier modifiable qui doit les recevoir.", + "importIcsFile": "Importer un fichier .ics", + "importIcsPreview": "Importer des événements du calendrier", + "importEventsFound": "Ensembles d’événements importables : {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Événements non valides : {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Exclus intentionnellement : {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "Aucun calendrier de destination modifiable n’est disponible.", + "importDestinationCalendar": "Calendrier de destination", + "importIcsConfirm": "Importer les événements", + "importIcsComplete": "Importation terminée", + "importQueued": "Importés ou mis en file d’attente : {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Doublons ignorés : {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Ensembles de récurrence non pris en charge : {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "Impossible d’importer le fichier de calendrier : {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "Hors ligne", "networkOfflineDescription": "Les modifications seront synchronisées au rétablissement de la connexion.", - "networkOfflineTryAgain": "Vous êtes hors ligne. Connectez-vous à Internet et réessayez." + "networkOfflineTryAgain": "Vous êtes hors ligne. Connectez-vous à Internet et réessayez.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "les {days} du mois", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first} et {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} et {second}", + "repeatMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} et {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} et {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} le {day} {month}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} le {day} de {months}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} les jours {days} de {months}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{le premier {days} de {months}} second{le deuxième {days} de {months}} third{le troisième {days} de {months}} fourth{le quatrième {days} de {months}} fifth{le cinquième {days} de {months}} secondToLast{l’avant-dernier {days} de {months}} last{le dernier {days} de {months}} other{les {days} de {months}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency} les jours {days} de {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{le premier {days} de {month}} second{le deuxième {days} de {month}} third{le troisième {days} de {month}} fourth{le quatrième {days} de {month}} fifth{le cinquième {days} de {month}} secondToLast{l’avant-dernier {days} de {month}} last{le dernier {days} de {month}} other{les {days} de {month}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index c7ebe000..ab45c469 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1,14 +1,31 @@ { "@@locale": "hi", + "windowsSupport": "सहायता", + "windowsThirdPartyLicenses": "तृतीय-पक्ष लाइसेंस", + "windowsSearch": "खोजें", + "windowsStartupDisabledByUser": "उपयोगकर्ता ने Windows सेटिंग में इसे बंद किया है।", + "windowsStartupDisabledByPolicy": "Windows नीति द्वारा बंद किया गया है।", + "windowsStartupUnavailable": "BusyMax को MSIX पैकेज से इंस्टॉल करने के बाद उपलब्ध होगा।", + "windowsReminderExitNotice": "BusyMax को पूरी तरह बंद करने पर रिमाइंडर रुक जाते हैं। उन्हें पाने के लिए इसे पृष्ठभूमि में चलने दें।", + "windowsProductVersionLabel": "उत्पाद संस्करण", + "windowsPackageVersionLabel": "Windows पैकेज संस्करण", + "windowsUnpackaged": "पैकेज रहित", + "windowsAgendaLoadMore": "एजेंडा के और आइटम लोड करें", + "repeatWeeklyDaySummary": "{dayKey, select, MO{सोमवार} TU{मंगलवार} WE{बुधवार} TH{गुरुवार} FR{शुक्रवार} SA{शनिवार} SU{रविवार} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "की {first} और {second} तारीखों को", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} {month} की {firstDay} और {secondDay} तारीखों को", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} {firstMonth} और {secondMonth} की {day} तारीख को", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} {firstMonth} और {secondMonth} की {firstDay} और {secondDay} तारीखों को", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} {firstMonth} और {secondMonth} की {days} तारीखों को", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Google, Microsoft, Apple iCloud Calendar या Nextcloud खाते कनेक्ट करें।", "googlePermissionsConsentNotice": "Google अनुमति स्क्रीन पर, कैलेंडर और कार्य दोनों अनुमतियाँ चुनें।", "googlePermissionsRequiredRetry": "Google Calendar और Google Tasks की अनुमतियाँ आवश्यक हैं। फिर से कोशिश करें और दोनों चेकबॉक्स चुनें।", "finishSetup": "सेटअप पूरा करें", "continueSetup": "जारी रखें", "onboardingSetupTitle": "BusyMax सेट अप करें", "onboardingAccountsStepTitle": "खाते कनेक्ट करें", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "वे सभी खाते जोड़ें जिनका आप उपयोग करना चाहते हैं। BusyMax हर खाते से समर्थित कैलेंडर, इवेंट, कार्य सूचियाँ और कार्य सिंक करता है।", "onboardingPreferencesStepTitle": "सिस्टम सेटिंग्स चुनें", "onboardingPreferencesStepDescription": "अपना शेड्यूल खोलने से पहले डेस्कटॉप व्यवहार, रिमाइंडर, सूचनाओं के विवरण का स्तर और दिखावट सेट करें।", "signInWithGoogle": "Google से साइन इन करें", @@ -29,11 +46,48 @@ "save": "सहेजें", "settings": "सेटिंग्स", "all": "सभी", - "calendarEvents": "ईवेंट", + "calendarEvents": "इवेंट", "calendarTasks": "कार्य", "calendar": "कैलेंडर", "calendars": "कैलेंडर", - "newEvent": "नया ईवेंट", + "newCalendar": "नया कैलेंडर", + "calendarColor": "कैलेंडर का रंग", + "calendarColorOption": "रंग {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "यह प्रदाता BusyMax में कैलेंडर प्रबंधन का समर्थन नहीं करता है।", + "primaryCalendarCannotDelete": "प्राथमिक कैलेंडर को हटाया नहीं जा सकता।", + "calendarCreateFailed": "कैलेंडर नहीं बनाया जा सका: {error}", + "calendarCreatedRefreshPending": "कैलेंडर बन गया, लेकिन BusyMax खाते को रीफ़्रेश नहीं कर सका। यह अगली सिंक के बाद दिखाई देगा।", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "कैलेंडर अपडेट नहीं किया जा सका: {error}", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "कैलेंडर हटाया नहीं जा सका: {error}", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "newEvent": "नया इवेंट", "refreshCalendar": "कैलेंडर रीफ़्रेश करें", "openInProvider": "सेवा में खोलें", "hideFromSchedule": "शेड्यूल से छिपाएँ", @@ -41,17 +95,66 @@ "noCalendarsSynced": "अभी तक कोई कैलेंडर सिंक नहीं हुआ है।", "allDay": "पूरे दिन", "moreItems": "+{count} और", - "noEventsOrTasks": "कोई ईवेंट या कार्य नहीं", + "noEventsOrTasks": "कोई इवेंट या कार्य नहीं", "scheduleLoading": "शेड्यूल लोड हो रहा है...", "scheduleUnavailable": "शेड्यूल उपलब्ध नहीं है", "scheduleNoSources": "कोई दिखाई देने वाला कैलेंडर या कार्य सूची नहीं", "scheduleNoSourcesDescription": "सेटिंग्स में चुनें कि क्या दिखाना है, फिर रीफ़्रेश करें।", "scheduleSignInRequired": "खाता कनेक्ट करें", "scheduleSignInDescription": "कैलेंडर और कार्य सिंक करने के लिए साइन इन करें।", - "scheduleNoSearchResults": "कोई मिलता-जुलता ईवेंट या कार्य नहीं", + "scheduleNoSearchResults": "कोई मिलता-जुलता इवेंट या कार्य नहीं", "scheduleNoSearchResultsDescription": "कोई दूसरी खोज आज़माएँ या मौजूदा फ़िल्टर हटाएँ।", "refresh": "रीफ़्रेश करें", "trayOpenBusyMax": "BusyMax खोलें", + "trayShowBusyMax": "BusyMax दिखाएँ", + "trayNewEvent": "नया इवेंट…", + "trayNewTask": "नया कार्य…", + "trayToday": "आज", + "trayAllDay": "पूरा दिन", + "trayNow": "अभी", + "trayCalendarEvent": "कैलेंडर इवेंट", + "trayUntitledEvent": "बिना शीर्षक का इवेंट", + "trayNothingElseToday": "आज और कुछ नहीं", + "trayTasksDueToday": "{count, plural, =1{आज 1 कार्य की नियत तारीख है} other{आज {count} कार्यों की नियत तारीख है}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "आज का एजेंडा खोलें", + "traySyncNow": "अभी सिंक करें", + "traySyncing": "सिंक हो रहा है…", + "trayNotConnected": "कनेक्ट नहीं है", + "trayNotYetSynced": "अभी तक सिंक नहीं हुआ", + "trayLastSyncedJustNow": "अभी-अभी सिंक हुआ", + "trayLastSyncedMinutesAgo": "{count, plural, =1{1 मिनट पहले सिंक हुआ} other{{count} मिनट पहले सिंक हुआ}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{1 घंटे पहले सिंक हुआ} other{{count} घंटे पहले सिंक हुआ}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{1 दिन पहले सिंक हुआ} other{{count} दिन पहले सिंक हुआ}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "सेटिंग्स", + "trayQuitBusyMax": "BusyMax बंद करें", "agendaLoadMoreOverdue": "समय-सीमा पार कर चुके अतिरिक्त कार्य लोड करें", "agendaLoadMoreNoDate": "बिना तारीख वाले और कार्य लोड करें", "viewDay": "दिन", @@ -67,10 +170,10 @@ "sourceCalendar": "कैलेंडर", "sourceTaskList": "कार्य सूची", "createChoiceTitle": "बनाएँ", - "createEventAtTime": "ईवेंट", + "createEventAtTime": "इवेंट", "createTaskAtDate": "कार्य", - "editEvent": "ईवेंट संपादित करें", - "eventTitle": "ईवेंट का शीर्षक", + "editEvent": "इवेंट संपादित करें", + "eventTitle": "इवेंट का शीर्षक", "location": "स्थान", "timeSlot": "समयावधि", "startDateTime": "शुरू होने की तारीख/समय", @@ -129,8 +232,8 @@ "shortcutYearView": "वर्ष का दृश्य", "shortcutAgendaView": "कार्यसूची दृश्य", "shortcutGroupCreateAndEdit": "बनाएँ और संपादित करें", - "shortcutSaveItem": "ईवेंट या कार्य सहेजें", - "shortcutDeleteItem": "ईवेंट या कार्य मिटाएँ", + "shortcutSaveItem": "इवेंट या कार्य सहेजें", + "shortcutDeleteItem": "इवेंट या कार्य मिटाएँ", "shortcutGroupTaskEditing": "कार्य संपादन", "shortcutCancelEditing": "संपादन रद्द करें", "shortcutCancelEditingDescription": "कार्य संपादन या कार्य विवरण बंद करें", @@ -154,7 +257,7 @@ "feedbackDetailedMessage": "विस्तृत संदेश", "feedbackReplyEmail": "जवाब के लिए ईमेल (वैकल्पिक)", "feedbackIncludeTechnicalDetails": "तकनीकी विवरण शामिल करें", - "feedbackTechnicalDetailsDisclosure": "केवल आपके Linux ऑपरेटिंग सिस्टम का संस्करण और ऐप की भाषा व क्षेत्रीय सेटिंग जोड़ी जाती है। लॉग, खाता डेटा, फ़ाइल नाम या अन्य निदान जानकारी शामिल नहीं की जाती।", + "feedbackTechnicalDetailsDisclosure": "केवल आपके ऑपरेटिंग सिस्टम का नाम और संस्करण तथा ऐप की भाषा व क्षेत्रीय सेटिंग जोड़ी जाती है। लॉग, खाता डेटा, फ़ाइल नाम या अन्य निदान जानकारी शामिल नहीं की जाती।", "feedbackCategoryRequired": "श्रेणी चुनें।", "feedbackSubjectLengthError": "विषय 3 से 120 वर्णों के बीच होना चाहिए।", "feedbackMessageLengthError": "संदेश 10 से 5,000 वर्णों के बीच होना चाहिए।", @@ -179,8 +282,8 @@ "removeAccount": "खाता हटाएँ…", "removingAccount": "खाता हटाया जा रहा है…", "removeAccountDescription": "सिंक करना बंद करें और इस डिवाइस से इस खाते का डेटा हटाएँ।", - "removeAccountTitle": "BusyMax से {account} हटाएँ?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountTitle": "{account} को BusyMax से हटाएँ?", + "removeAccountConfirmation": "इससे इस डिवाइस से कैश किए गए कार्य, कैलेंडर, इवेंट, रिमाइंडर और लंबित ऑफ़लाइन बदलाव हट जाएँगे। सिंक न किए गए बदलाव खो जाएँगे। प्रदाता की कैलेंडर, इवेंट, कार्य सूची और कार्य प्रतियाँ हटाई नहीं जाएँगी।", "revokeGoogleAccess": "इस Google खाते से BusyMax की पहुँच भी रद्द करें", "revokeGoogleAccessDescription": "दोबारा कनेक्ट करने से पहले आपको फिर से पहुँच देनी होगी।", "removeAccountAction": "खाता हटाएँ", @@ -196,8 +299,8 @@ "deleteList": "सूची मिटाएँ", "builtInMicrosoftList": "अंतर्निहित", "builtInMicrosoftListCannotRenameDelete": "Microsoft To Do की अंतर्निहित सूचियों का नाम बदला या उन्हें मिटाया नहीं जा सकता।", - "deleteListConfirmation": "Google Tasks से “{title}” मिटाएँ?", - "deleteEvent": "ईवेंट मिटाएँ", + "deleteListConfirmation": "Google Tasks से “{title}” हटाएँ?", + "deleteEvent": "इवेंट मिटाएँ", "title": "शीर्षक", "create": "बनाएँ", "newTask": "नया कार्य", @@ -291,7 +394,8 @@ "synced": "सिंक किया गया", "account": "खाता", "sync": "सिंक", - "manualFullSync": "मैन्युअल पूर्ण सिंक", + "forceFullResync": "पूर्ण पुनः सिंक करें", + "forceFullResyncDescription": "हर कनेक्ट किए गए खाते से सभी डेटा पूरी तरह फिर से लोड करता है। इसका उपयोग केवल सिंक संबंधी समस्याओं के निवारण के लिए करें।", "runInBackgroundWhenClosed": "विंडो बंद होने पर भी चलते रहें", "showTrayIcon": "ट्रे आइकन दिखाएँ", "startMinimizedToTray": "ट्रे में मिनिमाइज़ होकर शुरू करें", @@ -304,7 +408,8 @@ "notifySyncFailures": "सिंक विफल होने की सूचनाएँ", "notifyConflicts": "टकराव की सूचनाएँ", "notifyDueToday": "आज देय कार्यों की सूचनाएँ", - "eventReminders": "ईवेंट रिमाइंडर", + "eventReminders": "इवेंट रिमाइंडर", + "onState": "चालू", "taskReminders": "कार्य रिमाइंडर", "notificationDetailLevel": "सूचनाओं के विवरण का स्तर", "notificationDetailPrivate": "निजी", @@ -314,9 +419,13 @@ "quietHoursStart": "शांत समय की शुरुआत", "quietHoursEnd": "शांत समय की समाप्ति", "notifications": "सूचनाएँ", + "windowsNotificationsUnavailable": "Windows सूचनाएँ उपलब्ध नहीं हैं", + "windowsNotificationsUnpackaged": "यह बिना पैकेज वाला डेवलपमेंट रन Windows सूचनाओं का उपयोग नहीं कर सकता। रिमाइंडर जाँचने के लिए परीक्षण-हस्ताक्षरित MSIX इंस्टॉल करें।", + "windowsNotificationsInstalledFailure": "BusyMax Windows सूचनाएँ शुरू नहीं कर सका। यह इंस्टॉलेशन समस्या हल होने तक रिमाइंडर दिखाई नहीं देंगे।", "appearance": "दिखावट", "theme": "थीम", "themeSystem": "सिस्टम", + "settingsSystem": "सिस्टम", "themeLight": "हल्की", "themeDark": "गहरी", "themeFamily": "थीम परिवार", @@ -352,16 +461,16 @@ "syncFailureNotificationTitle": "BusyMax सिंक विफल", "syncFailureNotificationBody": "बैकग्राउंड सिंक विफल हुआ। {message}", "conflictNotificationTitle": "BusyMax सिंक में टकराव", - "conflictNotificationBody": "एक लंबित स्थानीय बदलाव अवरुद्ध हो गया। {summary}", + "conflictNotificationBody": "लंबित स्थानीय बदलाव अवरुद्ध किया गया। {summary}", "dueTodayNotificationTitle": "आज देय कार्य", "dueTodayNotificationBody": "{count, plural, =1{आज एक कार्य देय है।} other{आज {count} कार्य देय हैं।}}", - "eventReminderNotificationTitle": "ईवेंट रिमाइंडर", + "eventReminderNotificationTitle": "इवेंट रिमाइंडर", "taskReminderNotificationTitle": "कार्य रिमाइंडर", - "eventReminderNotificationBody": "ईवेंट जल्द शुरू होगा।", + "eventReminderNotificationBody": "इवेंट जल्द शुरू होगा।", "taskReminderNotificationBody": "कार्य जल्द देय है।", "notificationOpenAction": "खोलें", "notificationSnoozeAction": "10 मिनट के लिए स्नूज़ करें", - "notificationDismissAction": "खारिज करें", + "notificationDismissAction": "बंद करें", "notificationDetailsHidden": "गोपनीयता सेटिंग्स के कारण विवरण छिपे हुए हैं।", "previousMonth": "पिछला महीना", "nextMonth": "अगला महीना", @@ -376,50 +485,50 @@ "selectTimeZone": "समय क्षेत्र चुनें", "searchLocations": "स्थान खोजें", "noLocationsFound": "कोई स्थान नहीं मिला", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "यह फ़ील्ड आवश्यक है।", + "providerConnectionDescription": "इनमें से किसी प्रदाता से कैलेंडर और कार्य कनेक्ट करें।", + "appleICloudProvider": "Apple iCloud कैलेंडर", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloud कार्य", + "addAppleICloudAccount": "Apple iCloud Calendar खाता जोड़ें", + "addNextcloudAccount": "Nextcloud खाता जोड़ें", + "waitingForAppleICloud": "Apple iCloud से कनेक्ट हो रहा है…", + "waitingForNextcloud": "Nextcloud प्राधिकरण की प्रतीक्षा है…", + "connectAppleICloudTitle": "Apple iCloud Calendar कनेक्ट करें", + "appleAccountEmail": "Apple खाते का ईमेल", + "appleAppSpecificPassword": "ऐप-विशिष्ट पासवर्ड", + "appleAppSpecificPasswordHelp": "Apple खाते के लिए दो-चरणीय प्रमाणीकरण चालू करने के बाद ऐप-विशिष्ट पासवर्ड बनाएँ।", + "appleAppSpecificPasswordResetWarning": "Apple खाते का पासवर्ड रीसेट करने से ऐप-विशिष्ट पासवर्ड रद्द हो जाते हैं।", + "connectNextcloudTitle": "Nextcloud कनेक्ट करें", + "nextcloudServerUrl": "Nextcloud सर्वर या CalDAV पता", + "nextcloudServerUrlHelp": "Nextcloud सर्वर URL दर्ज करें या Nextcloud से कॉपी किया गया प्राथमिक CalDAV पता चिपकाएँ।", + "nextcloudBrowserAuthorizationHelp": "BusyMax आपका ब्राउज़र खोलेगा। वहाँ पहुँच की अनुमति दें और फिर BusyMax पर लौटें।", + "connectAccountAction": "कनेक्ट करें", + "cancelAccountConnection": "कनेक्शन रद्द करें", + "nextcloudAccountRemovedRevokeFailed": "खाता स्थानीय रूप से हटाया गया, लेकिन Nextcloud ऐप पासवर्ड रद्द नहीं किया जा सका।", + "davCachedOfflineNotice": "ऑफ़लाइन उपयोग के लिए कैलेंडर और कार्य डेटा स्थानीय रूप से कैश किया जाता है।", + "davReauthenticationRequired": "सिंक फिर से शुरू करने के लिए इस खाते को दोबारा कनेक्ट करें।", + "davTemporarilyUnavailable": "यह खाता अस्थायी रूप से उपलब्ध नहीं है।", + "davPermissionChanged": "सर्वर अनुमतियाँ बदल गई हैं। लंबित संपादन रोक दिए गए हैं।", + "davUnsupportedServer": "यह सर्वर या प्रदाता प्रोफ़ाइल समर्थित नहीं है।", + "collectionSettings": "कैलेंडर और कार्य सूचियाँ", + "calendarContent": "कैलेंडर इवेंट", + "taskContent": "कार्य", + "readOnlySharedCollection": "केवल-पठन", + "pendingLocally": "स्थानीय रूप से लंबित", + "conflictBlocked": "विरोध के कारण अवरुद्ध", + "authenticationBlocked": "दोबारा कनेक्ट होने तक अवरुद्ध", + "operationFailed": "कार्रवाई विफल", + "keepServerVersion": "सर्वर संस्करण रखें", + "reapplyLocalChange": "स्थानीय बदलाव की समीक्षा कर फिर लागू करें", + "duplicateLocalItem": "नए आइटम के रूप में कॉपी बनाएँ", "deleteCalendarConfirmation": "“{title}” मिटाएँ?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "कनेक्शन की स्थिति", + "davConnected": "कनेक्टेड", + "davConnecting": "कनेक्ट हो रहा है…", + "davSignedOut": "साइन आउट किया गया", + "davLastSuccessfulSync": "अंतिम सफल सिंक: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -427,9 +536,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "अभी तक सिंक नहीं हुआ", + "refreshCollections": "कैलेंडर और कार्य सूचियाँ रीफ़्रेश करें", + "nextcloudServerHost": "सर्वर: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -437,12 +546,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "इवेंट कैलेंडर", + "collectionSupportsTasks": "कार्य सूची", + "collectionSupportsEventsAndTasks": "इवेंट और कार्य", + "writableCollection": "लिखने योग्य", + "sharedCollection": "साझा", + "collectionLastSynced": "अंतिम सिंक: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -450,7 +559,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "सिंक समस्या: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -458,8 +567,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "सिंक विरोध", + "remoteChangedAt": "सर्वर में बदलाव: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -467,7 +576,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "स्थानीय संपादन: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -475,144 +584,366 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "conflictResolutionFailed": "विरोध का समाधान नहीं हो सका।", + "recurringEventScope": "दोहराए जाने वाले इवेंट का दायरा", + "entireSeries": "पूरी शृंखला", + "singleOccurrence": "यह इवेंट", + "thisAndFollowingEvents": "यह और इसके बाद के इवेंट", + "thisAndFutureUnavailable": "यह प्रदाता इसका समर्थन नहीं करता।", + "thisAndFutureMoveUnavailable": "इस और इसके बाद के इवेंट सुरक्षित रूप से नहीं ले जाए जा सकते। यह इवेंट या पूरी शृंखला चुनें।", + "entireSeriesMoveUnavailable": "दोहराव का नियम स्थानीय रूप से उपलब्ध नहीं है। इसके बजाय केवल यह इवेंट ले जाएँ।", + "copyEventAndDeleteOriginal": "इवेंट की कॉपी बनाकर मूल इवेंट मिटाएँ?", + "copyEventMoveWarning": "BusyMax इस इवेंट को {source} से {destination} में सीधे नहीं ले जा सकता। यह पहले कॉपी बनाएगा और कॉपी सफल होने के बाद ही मूल इवेंट मिटाएगा। इवेंट ID बदलेंगे; सहभागियों की प्रतिक्रिया स्थितियां रीसेट हो सकती हैं और आमंत्रण या रद्दीकरण भेजे जा सकते हैं; और कॉन्फ़्रेंस लिंक, अटैचमेंट, रिमाइंडर, प्रदाता-विशिष्ट फ़ील्ड तथा दोहराव अपवाद शायद स्थानांतरित न हों।", + "copyAndDelete": "कॉपी बनाएँ और मिटाएँ", + "chooseRecurringEventScope": "चुनें कि यह बदलाव पूरी शृंखला, केवल इस इवेंट या इस और इसके बाद के इवेंट पर लागू हो।", "taskDueBeforeStart": "नियत समय प्रारंभ समय से पहले नहीं हो सकता।", "taskStartDueTimeModeMismatch": "प्रारंभ और नियत समय दोनों सेट करें, या कार्य को पूरे दिन का बनाएँ।", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", - "unshare": "Unshare", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", - "taskStatusNeedsAction": "Needs action", - "taskStatusInProcess": "In process", - "taskStatusCompleted": "Completed", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", - "priority": "Priority", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "No reminders", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Before the task starts", - "beforeTaskDue": "Before the task is due", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "Repeat every", - "repeatOn": "Repeat on", - "repeatEnd": "End repeat", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", - "repeatOnMonthDaysSummary": "on day {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", - "repeatTimesSummary": "{count} times", - "repeatUntilSummary": "until {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", - "hideSubtasks": "Hide subtasks", - "hideClosedSubtasks": "Hide closed subtasks", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "taskListCreateFailed": "कार्य सूची बनाई नहीं जा सकी: {error}", + "taskListRenameFailed": "कार्य सूची का नाम बदला नहीं जा सका: {error}", + "taskListDeleteFailed": "कार्य सूची हटाई नहीं जा सकी: {error}", + "unshare": "साझा करना बंद करें", + "readOnlyTaskListCannotRename": "यह कार्य सूची केवल-पठन है और इसका नाम नहीं बदला जा सकता।", + "taskListCannotDelete": "आपकी मौजूदा अनुमतियों से यह कार्य सूची हटाई नहीं जा सकती।", + "deleteTaskListConfirmation": "“{title}” और इसके सभी कार्य हटाएँ?", + "unshareTaskListConfirmation": "इस खाते से “{title}” साझा करना बंद करें?", + "taskStatus": "स्थिति", + "taskStatusNone": "कोई स्थिति नहीं", + "taskStatusNeedsAction": "कार्रवाई आवश्यक", + "taskStatusInProcess": "प्रगति में", + "taskStatusCompleted": "पूर्ण", + "taskStatusCancelled": "रद्द", + "completionPercent": "{percent}% पूर्ण", + "completionDate": "पूर्ण होने की तारीख", + "priority": "प्राथमिकता", + "priorityNone": "कोई प्राथमिकता नहीं", + "priorityHighValue": "प्राथमिकता {priority} · उच्च", + "priorityMediumValue": "प्राथमिकता {priority} · मध्यम", + "priorityLowValue": "प्राथमिकता {priority} · निम्न", + "taskUrl": "कार्य URL", + "invalidTaskUrl": "स्कीम सहित पूर्ण URL दर्ज करें।", + "classification": "वर्गीकरण", + "classificationPublic": "साझा होने पर पूरा कार्य दिखाएँ", + "classificationConfidential": "साझा होने पर केवल व्यस्तता दिखाएँ", + "classificationPrivate": "साझा होने पर यह कार्य छिपाएँ", + "pinTask": "कार्य पिन करें", + "reminders": "रिमाइंडर", + "noReminders": "कोई रिमाइंडर नहीं", + "editReminder": "रिमाइंडर संपादित करें", + "beforeTaskStarts": "कार्य शुरू होने से पहले", + "beforeTaskDue": "कार्य की नियत तारीख से पहले", + "afterTaskStarts": "कार्य शुरू होने के बाद", + "afterTaskDue": "कार्य की नियत तारीख के बाद", + "relativeToTaskStart": "कार्य शुरू होने की तारीख के सापेक्ष", + "relativeToTaskDue": "कार्य की नियत तारीख के सापेक्ष", + "reminderTimeOfDay": "दिन का समय", + "absoluteReminder": "तारीख और समय पर", + "reminderAmount": "मात्रा", + "reminderUnitSeconds": "सेकंड", + "reminderUnitMinutes": "मिनट", + "reminderUnitHours": "घंटे", + "reminderUnitDays": "दिन", + "reminderUnitWeeks": "सप्ताह", + "reminderAtTaskStart": "कार्य शुरू होने पर", + "reminderAtTaskDue": "कार्य की नियत तारीख पर", + "unsupportedReminder": "यह रिमाइंडर प्रकार सुरक्षित है, लेकिन इसका समय संपादित नहीं किया जा सकता।", + "relatedRemindersTitle": "संबंधित रिमाइंडर रखें?", + "relatedRemindersDescription": "इस तारीख पर {count} संबंधित रिमाइंडर हैं। इन्हें उनकी मौजूदा तारीख और समय पर रखें?", + "discardRelatedReminders": "रिमाइंडर हटाएँ", + "keepRelatedReminders": "रिमाइंडर रखें", + "repeatEvery": "दोहराव अंतराल", + "repeatOn": "दोहराएँ", + "repeatEnd": "दोहराव समाप्त करें", + "repeatNever": "कभी नहीं", + "repeatUntil": "तारीख तक", + "repeatAfter": "कई बार दोहराने के बाद", + "repeatCount": "दोहराव की संख्या", + "repeatDayOfMonth": "महीने के दिन", + "repeatMonths": "महीने", + "repeatOrdinal": "सप्ताह के दिन की स्थिति", + "repeatSpecificDays": "विशिष्ट दिन", + "repeatFirst": "पहला", + "repeatSecond": "दूसरा", + "repeatThird": "तीसरा", + "repeatFourth": "चौथा", + "repeatFifth": "पाँचवाँ", + "repeatSecondToLast": "अंतिम से दूसरा", + "repeatLast": "अंतिम", + "repeatAnyDay": "दिन", + "repeatWeekday": "सप्ताह का दिन", + "repeatWeekendDay": "सप्ताहांत का दिन", + "repeatOrdinalDaySummary": "{dayKey, select, MO{सोमवार} TU{मंगलवार} WE{बुधवार} TH{गुरुवार} FR{शुक्रवार} SA{शनिवार} SU{रविवार} day{दिन} weekday{सप्ताह का दिन} weekend{सप्ताहांत का दिन} other{{day}}}", + "repeatEveryDays": "हर {count} दिन", + "repeatEveryWeeks": "हर {count} सप्ताह", + "repeatEveryMonths": "हर {count} महीने", + "repeatEveryYears": "हर {count} साल", + "repeatOnDaysSummary": "{days} को", + "repeatOnMonthDaysSummary": "की {days} तारीख को", + "repeatOnOrdinalSummary": "{position, select, first{के पहले {days} को} second{के दूसरे {days} को} third{के तीसरे {days} को} fourth{के चौथे {days} को} fifth{के पाँचवें {days} को} secondToLast{के अंतिम से दूसरे {days} को} last{के अंतिम {days} को} other{के {days} को}}", + "repeatInMonthsSummary": "{months} में", + "repeatTimesSummary": "{count, plural, one{{count} बार} other{{count} बार}}", + "repeatUntilSummary": "{date} तक", + "unsupportedRecurrencePreserved": "यह दोहराव नियम ऐसे विकल्पों का उपयोग करता है जिन्हें यह संपादक नहीं बदलता।", + "recurrenceUnsupportedByProvider": "इस पुनरावृत्ति का उपयोग {provider} के साथ नहीं किया जा सकता।", + "duplicateTask": "कार्य की प्रतिलिपि बनाएँ", + "taskDuplicated": "कार्य की प्रतिलिपि बन गई।", + "taskDuplicateFailed": "कार्य की प्रतिलिपि नहीं बन सकी: {error}", + "hideSubtasks": "उपकार्य छिपाएँ", + "hideClosedSubtasks": "बंद उपकार्य छिपाएँ", + "reminderUnit": "इकाई", + "attendeeRequired": "ज़रूरी", + "attendeeOptional": "वैकल्पिक", + "meetingSection": "मीटिंग", + "addGoogleMeet": "Google Meet जोड़ें", + "addTeamsMeeting": "Microsoft Teams मीटिंग जोड़ें", + "onlineMeetingAdded": "ऑनलाइन मीटिंग जोड़ी गई", + "requestResponses": "जवाब माँगें", + "requestResponsesDescription": "अतिथियों से आमंत्रण का जवाब देने को कहें।", + "hideGuestList": "अतिथि सूची छिपाएँ", + "hideGuestListDescription": "अतिथि यह नहीं देख सकते कि और किसे आमंत्रित किया गया है।", + "allowNewTimeProposals": "नए समय प्रस्तावों की अनुमति दें", + "allowNewTimeProposalsDescription": "अतिथि मीटिंग का अलग समय सुझा सकते हैं।", + "notifyGuestsTitle": "अतिथियों को सूचित करें?", + "notifyGuestsSaveMessage": "इस मीटिंग में अतिथि हैं। सहेजते समय आमंत्रण या इवेंट अपडेट भेजें?", + "notifyGuestsDeleteMessage": "इस मीटिंग में अतिथि हैं। हटाते समय रद्दीकरण भेजें?", + "sendUpdates": "अपडेट भेजें", + "sendCancellation": "रद्दीकरण भेजें", + "doNotSend": "न भेजें", + "microsoftNotifyGuestsSaveTitle": "मीटिंग सहेजें?", + "microsoftNotifyGuestsSaveMessage": "Microsoft अतिथियों को आमंत्रण या इवेंट अपडेट भेजेगा।", + "microsoftNotifyGuestsDeleteTitle": "मीटिंग हटाएँ?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft अतिथियों को रद्दीकरण भेजेगा।", + "organizer": "आयोजक", + "yourResponse": "आपका जवाब", + "guestResponses": "अतिथियों के जवाब", + "respond": "जवाब दें", + "acceptInvitation": "स्वीकार करें", + "tentativeInvitation": "अनिश्चित", + "declineInvitation": "अस्वीकार करें", + "joinMeeting": "मीटिंग में शामिल हों", + "responseAccepted": "स्वीकृत", + "responseTentative": "अनिश्चित", + "responseDeclined": "अस्वीकृत", + "responseNeedsAction": "जवाब की प्रतीक्षा है", + "responseNotResponded": "जवाब नहीं दिया", + "responseOrganizer": "आयोजक", + "invitationResponseFailed": "आपका जवाब भेजा नहीं जा सका: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "मीटिंग लिंक खोला नहीं जा सका।", + "setCustomCalendarName": "कस्टम नाम सेट करें", + "setAction": "सेट करें", + "removeFromMyCalendars": "मेरे कैलेंडर से हटाएँ", + "removeAction": "हटाएँ", + "removeCalendarConfirmation": "क्या \"{title}\" को आपकी Google Calendar सूची से हटाएँ? साझा कैलेंडर और उसके इवेंट नहीं मिटाए जाएँगे।", + "calendarCannotRemove": "इस कैलेंडर को इस खाते से हटाया या मिटाया नहीं जा सकता।", + "calendarPendingChangesPreventRemoval": "कैलेंडर हटाने से पहले उसके लंबित बदलावों का सिंक पूरा होने की प्रतीक्षा करें।", + "calendarSubscriptions": "कैलेंडर सदस्यताएँ", + "calendarSubscriptionsDescription": "सुरक्षित WebCal URL से रीफ़्रेश होने वाले केवल-पठन कैलेंडर जोड़ें।", + "addCalendarSubscription": "कैलेंडर सदस्यता जोड़ें", + "subscriptionName": "स्थानीय नाम", + "subscriptionUrl": "सदस्यता URL", + "subscriptionUrlHelp": "HTTPS या webcal URL दर्ज करें। BusyMax पूरा URL सुरक्षित संग्रहण में रखता है।", + "subscriptionUrlInvalid": "उपयोगकर्ता जानकारी या फ़्रैगमेंट के बिना मान्य HTTPS या webcal URL दर्ज करें।", + "subscriptionColor": "स्थानीय रंग", + "subscriptionColorHelp": "#3584E4 जैसे छह अंकों वाले रंग का उपयोग करें।", + "subscriptionColorInvalid": "छह अंकों वाला हेक्साडेसिमल रंग दर्ज करें।", + "subscriptionRefreshMode": "रीफ़्रेश आवृत्ति", + "subscriptionAutomatic": "स्वचालित", + "subscriptionHourly": "हर घंटे", + "subscriptionSixHours": "हर छह घंटे", + "subscriptionDaily": "हर दिन", + "subscriptionSafeOrigin": "स्रोत: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "सुरक्षित स्रोत का पूर्वावलोकन करने के लिए मान्य URL दर्ज करें।", + "subscriptionReadOnly": "केवल-पठन सदस्यता", + "subscriptionNeverRefreshed": "अभी तक रीफ़्रेश नहीं हुआ", + "subscriptionLastRefresh": "अंतिम सफल रीफ़्रेश: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "अगला रीफ़्रेश: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "अप टू डेट", + "subscriptionStatusIssue": "रीफ़्रेश समस्या: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "अभी रीफ़्रेश करें", + "unsubscribe": "सदस्यता रद्द करें", + "unsubscribeCalendarTitle": "“{name}” की सदस्यता रद्द करें?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "इससे स्थानीय सदस्यता और कैश किए गए इवेंट हट जाएँगे। प्रकाशित कैलेंडर नहीं बदलेगा।", + "addSubscriptionAction": "सदस्यता जोड़ें", + "subscriptionOperationFailed": "कैलेंडर सदस्यता विफल: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "सदस्यताएँ", + "calendarImport": "कैलेंडर आयात", + "calendarImportDescription": "फ़ाइल चुनें, उसके इवेंट की समीक्षा करें और फिर वह लिखने योग्य कैलेंडर चुनें जिसे उन्हें प्राप्त करना चाहिए।", + "importIcsFile": ".ics फ़ाइल आयात करें", + "importIcsPreview": "कैलेंडर इवेंट आयात करें", + "importEventsFound": "आयात योग्य इवेंट सेट: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "अमान्य इवेंट: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "जानबूझकर छोड़े गए: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "कोई लिखने योग्य गंतव्य कैलेंडर उपलब्ध नहीं है।", + "importDestinationCalendar": "गंतव्य कैलेंडर", + "importIcsConfirm": "इवेंट आयात करें", + "importIcsComplete": "आयात पूरा हुआ", + "importQueued": "आयातित या कतार में: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "डुप्लिकेट छोड़े गए: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "असमर्थित दोहराव सेट: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "कैलेंडर फ़ाइल आयात नहीं की जा सकी: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "ऑफ़लाइन", "networkOfflineDescription": "कनेक्शन वापस आने पर बदलाव सिंक हो जाएंगे।", - "networkOfflineTryAgain": "आप ऑफ़लाइन हैं। इंटरनेट से कनेक्ट करें और फिर से कोशिश करें।" + "networkOfflineTryAgain": "आप ऑफ़लाइन हैं। इंटरनेट से कनेक्ट करें और फिर से कोशिश करें।", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "की {days} तारीखों को", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first} और {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} और {second}", + "repeatMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} और {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} और {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} {month} की {day} तारीख को", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} {months} की {day} तारीख को", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} {months} की {days} तारीखों को", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {months} के {position, select, first{पहले} second{दूसरे} third{तीसरे} fourth{चौथे} fifth{पाँचवें} secondToLast{अंतिम से दूसरे} last{अंतिम} other{}} {days} को", + "repeatYearlyOnMonthDaysSummary": "{frequency} {month} की {days} तारीखों को", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {month} के {position, select, first{पहले} second{दूसरे} third{तीसरे} fourth{चौथे} fifth{पाँचवें} secondToLast{अंतिम से दूसरे} last{अंतिम} other{}} {days} को", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 5a923f0e..40604746 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1,14 +1,31 @@ { "@@locale": "it", + "windowsSupport": "Supporto", + "windowsThirdPartyLicenses": "Licenze di terze parti", + "windowsSearch": "Cerca", + "windowsStartupDisabledByUser": "Disabilitato dall’utente nelle impostazioni di Windows.", + "windowsStartupDisabledByPolicy": "Disabilitato da un criterio di Windows.", + "windowsStartupUnavailable": "Disponibile dopo l’installazione di BusyMax da un pacchetto MSIX.", + "windowsReminderExitNotice": "I promemoria si interrompono quando BusyMax viene chiuso completamente. Lascialo in esecuzione in background per riceverli.", + "windowsProductVersionLabel": "Versione del prodotto", + "windowsPackageVersionLabel": "Versione del pacchetto Windows", + "windowsUnpackaged": "Non pacchettizzato", + "windowsAgendaLoadMore": "Carica altri elementi dell’agenda", + "repeatWeeklyDaySummary": "{dayKey, select, MO{il lunedì} TU{il martedì} WE{il mercoledì} TH{il giovedì} FR{il venerdì} SA{il sabato} SU{la domenica} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "nei giorni {first} e {second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} nei giorni {firstDay} e {secondDay} di {month}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} il giorno {day} di {firstMonth} e {secondMonth}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} nei giorni {firstDay} e {secondDay} di {firstMonth} e {secondMonth}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} nei giorni {days} di {firstMonth} e {secondMonth}", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Collega gli account Google, Microsoft, Calendario Apple iCloud o Nextcloud.", "googlePermissionsConsentNotice": "Nella schermata delle autorizzazioni di Google, seleziona sia l’autorizzazione per il calendario sia quella per le attività.", "googlePermissionsRequiredRetry": "Sono necessarie le autorizzazioni per Google Calendar e Google Tasks. Riprova e seleziona entrambe le caselle.", "finishSetup": "Completa la configurazione", "continueSetup": "Continua", "onboardingSetupTitle": "Configura BusyMax", "onboardingAccountsStepTitle": "Collega gli account", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "Aggiungi tutti gli account che vuoi usare. BusyMax sincronizza calendari, eventi, elenchi di attività e attività supportati da ogni account.", "onboardingPreferencesStepTitle": "Scegli le impostazioni di sistema", "onboardingPreferencesStepDescription": "Prima di aprire l’agenda, configura il comportamento sul desktop, i promemoria, il livello di dettaglio delle notifiche e l’aspetto.", "signInWithGoogle": "Accedi con Google", @@ -33,6 +50,43 @@ "calendarTasks": "Attività", "calendar": "Calendario", "calendars": "Calendari", + "newCalendar": "Nuovo calendario", + "calendarColor": "Colore del calendario", + "calendarColorOption": "Colore {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "Questo provider non supporta la gestione dei calendari in BusyMax.", + "primaryCalendarCannotDelete": "Il calendario principale non può essere eliminato.", + "calendarCreateFailed": "Impossibile creare il calendario: {error}", + "calendarCreatedRefreshPending": "Il calendario è stato creato, ma BusyMax non ha potuto aggiornare l’account. Apparirà dopo la prossima sincronizzazione.", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "Impossibile aggiornare il calendario: {error}", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "Impossibile eliminare il calendario: {error}", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "Nuovo evento", "refreshCalendar": "Aggiorna calendario", "openInProvider": "Apri nel servizio", @@ -52,13 +106,62 @@ "scheduleNoSearchResultsDescription": "Prova una ricerca diversa o cancella i filtri attuali.", "refresh": "Aggiorna", "trayOpenBusyMax": "Apri BusyMax", + "trayShowBusyMax": "Mostra BusyMax", + "trayNewEvent": "Nuovo evento…", + "trayNewTask": "Nuova attività…", + "trayToday": "Oggi", + "trayAllDay": "Tutto il giorno", + "trayNow": "Adesso", + "trayCalendarEvent": "Evento del calendario", + "trayUntitledEvent": "Evento senza titolo", + "trayNothingElseToday": "Nient’altro oggi", + "trayTasksDueToday": "{count, plural, =1{1 attività scade oggi} other{{count} attività scadono oggi}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Apri l’agenda di oggi", + "traySyncNow": "Sincronizza ora", + "traySyncing": "Sincronizzazione…", + "trayNotConnected": "Non connesso", + "trayNotYetSynced": "Non ancora sincronizzato", + "trayLastSyncedJustNow": "Sincronizzato proprio ora", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Sincronizzato 1 minuto fa} other{Sincronizzato {count} minuti fa}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Sincronizzato 1 ora fa} other{Sincronizzato {count} ore fa}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Sincronizzato 1 giorno fa} other{Sincronizzato {count} giorni fa}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Impostazioni", + "trayQuitBusyMax": "Esci da BusyMax", "agendaLoadMoreOverdue": "Carica altre attività scadute", "agendaLoadMoreNoDate": "Carica altre attività senza data", "viewDay": "Giorno", "viewWeek": "Settimana", "viewMonth": "Mese", "viewYear": "Anno", - "viewAgenda": "Agenda", + "viewAgenda": "Vista agenda", "scheduleSettings": "Agenda", "scheduleDisplaySettings": "Visualizzazione agenda", "scheduleDisplayHoursDescription": "Le viste Giorno e Settimana mostrano inizialmente questo intervallo orario. Gli elementi precedenti o successivi lo estendono quando necessario.", @@ -154,7 +257,7 @@ "feedbackDetailedMessage": "Messaggio dettagliato", "feedbackReplyEmail": "Indirizzo email per la risposta (facoltativo)", "feedbackIncludeTechnicalDetails": "Includi dettagli tecnici", - "feedbackTechnicalDetailsDisclosure": "Aggiunge soltanto la versione del sistema operativo Linux e le impostazioni locali dell’applicazione. Non vengono inclusi log, dati degli account, nomi di file o altre informazioni diagnostiche.", + "feedbackTechnicalDetailsDisclosure": "Aggiunge soltanto il nome e la versione del sistema operativo e le impostazioni locali dell’applicazione. Non vengono inclusi log, dati degli account, nomi di file o altre informazioni diagnostiche.", "feedbackCategoryRequired": "Seleziona una categoria.", "feedbackSubjectLengthError": "L’oggetto deve contenere da 3 a 120 caratteri.", "feedbackMessageLengthError": "Il messaggio deve contenere da 10 a 5.000 caratteri.", @@ -180,7 +283,7 @@ "removingAccount": "Rimozione account…", "removeAccountDescription": "Interrompi la sincronizzazione e rimuovi i dati di questo account dal dispositivo.", "removeAccountTitle": "Rimuovere {account} da BusyMax?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "Questa operazione elimina da questo dispositivo attività, calendari, eventi, promemoria e modifiche offline in sospeso memorizzati nella cache. Le modifiche non sincronizzate andranno perse. Le copie del provider non verranno eliminate.", "revokeGoogleAccess": "Revoca anche l’accesso di BusyMax a questo account Google", "revokeGoogleAccessDescription": "Dovrai concedere nuovamente l’accesso prima di riconnetterti.", "removeAccountAction": "Rimuovi account", @@ -196,7 +299,7 @@ "deleteList": "Elimina elenco", "builtInMicrosoftList": "Integrato", "builtInMicrosoftListCannotRenameDelete": "Gli elenchi integrati di Microsoft To Do non possono essere rinominati o eliminati.", - "deleteListConfirmation": "Eliminare «{title}» da Google Tasks?", + "deleteListConfirmation": "Eliminare “{title}” da Google Tasks?", "deleteEvent": "Elimina evento", "title": "Titolo", "create": "Crea", @@ -289,9 +392,10 @@ "localState": "Stato locale", "pendingSync": "Sincronizzazione in sospeso", "synced": "Sincronizzato", - "account": "Account", + "account": "Profilo", "sync": "Sincronizzazione", - "manualFullSync": "Sincronizzazione completa manuale", + "forceFullResync": "Forza una risincronizzazione completa", + "forceFullResyncDescription": "Ricarica completamente i dati di ogni account connesso. Usa questa opzione solo per risolvere problemi di sincronizzazione.", "runInBackgroundWhenClosed": "Continua a funzionare quando la finestra è chiusa", "showTrayIcon": "Mostra icona nell’area di notifica", "startMinimizedToTray": "Avvia ridotto a icona nell’area di notifica", @@ -305,6 +409,7 @@ "notifyConflicts": "Notifiche in caso di conflitto", "notifyDueToday": "Notifiche per attività in scadenza oggi", "eventReminders": "Promemoria degli eventi", + "onState": "Attivato", "taskReminders": "Promemoria delle attività", "notificationDetailLevel": "Livello di dettaglio delle notifiche", "notificationDetailPrivate": "Privato", @@ -314,16 +419,20 @@ "quietHoursStart": "Inizio delle ore di silenzio", "quietHoursEnd": "Fine delle ore di silenzio", "notifications": "Notifiche", + "windowsNotificationsUnavailable": "Le notifiche di Windows non sono disponibili", + "windowsNotificationsUnpackaged": "Questa esecuzione di sviluppo non pacchettizzata non può usare le notifiche di Windows. Installa il pacchetto MSIX firmato per i test per verificare i promemoria.", + "windowsNotificationsInstalledFailure": "BusyMax non ha potuto inizializzare le notifiche di Windows. I promemoria non appariranno finché il problema di installazione non sarà risolto.", "appearance": "Aspetto", "theme": "Tema", "themeSystem": "Sistema", + "settingsSystem": "Sistema", "themeLight": "Chiaro", "themeDark": "Scuro", "themeFamily": "Famiglia di temi", "themeFamilyYaru": "Tema nativo di Ubuntu (Yaru)", "localization": "Lingua e area geografica", "currentLocale": "Impostazioni locali correnti", - "privacy": "Privacy", + "privacy": "Riservatezza", "redactTaskContentInDiagnostics": "Nascondi il contenuto delle attività nelle informazioni diagnostiche", "developerDiagnostics": "Diagnostica per sviluppatori", "diagnostics": "Diagnostica", @@ -361,7 +470,7 @@ "taskReminderNotificationBody": "L’attività scadrà a breve.", "notificationOpenAction": "Apri", "notificationSnoozeAction": "Posticipa di 10 minuti", - "notificationDismissAction": "Ignora", + "notificationDismissAction": "Chiudi", "notificationDetailsHidden": "I dettagli sono nascosti dalle impostazioni sulla privacy.", "previousMonth": "Mese precedente", "nextMonth": "Mese successivo", @@ -374,52 +483,52 @@ "scheduleItemCount": "{count, plural, =1{1 elemento} other{{count} elementi}}", "readOnlyCalendar": "Questo calendario è di sola lettura.", "selectTimeZone": "Seleziona fuso orario", - "searchLocations": "Cerca luoghi", - "noLocationsFound": "Nessun luogo trovato", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "searchLocations": "Cerca località", + "noLocationsFound": "Nessuna località trovata", + "requiredField": "Questo campo è obbligatorio.", + "providerConnectionDescription": "Collega calendari e attività a uno di questi provider.", + "appleICloudProvider": "Calendario Apple iCloud", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Attività Nextcloud", + "addAppleICloudAccount": "Aggiungi account Calendario Apple iCloud", + "addNextcloudAccount": "Aggiungi account Nextcloud", + "waitingForAppleICloud": "Connessione ad Apple iCloud…", + "waitingForNextcloud": "In attesa dell’autorizzazione Nextcloud…", + "connectAppleICloudTitle": "Collega Calendario Apple iCloud", + "appleAccountEmail": "E-mail dell’account Apple", + "appleAppSpecificPassword": "Password specifica per l’app", + "appleAppSpecificPasswordHelp": "Crea una password specifica per l’app dopo aver attivato l’autenticazione a due fattori per il tuo account Apple.", + "appleAppSpecificPasswordResetWarning": "La reimpostazione della password dell’account Apple revoca le password specifiche per le app.", + "connectNextcloudTitle": "Collega Nextcloud", + "nextcloudServerUrl": "Server Nextcloud o indirizzo CalDAV", + "nextcloudServerUrlHelp": "Inserisci l’URL del server Nextcloud o incolla l’indirizzo CalDAV principale copiato da Nextcloud.", + "nextcloudBrowserAuthorizationHelp": "BusyMax aprirà il browser. Approva l’accesso e torna a BusyMax.", + "connectAccountAction": "Collega", + "cancelAccountConnection": "Annulla connessione", + "nextcloudAccountRemovedRevokeFailed": "L’account è stato rimosso localmente, ma non è stato possibile revocare la password dell’app Nextcloud.", + "davCachedOfflineNotice": "I dati di calendari e attività vengono memorizzati localmente per l’uso offline.", + "davReauthenticationRequired": "Ricollega questo account per riprendere la sincronizzazione.", + "davTemporarilyUnavailable": "Questo account è temporaneamente non disponibile.", + "davPermissionChanged": "Le autorizzazioni del server sono cambiate. Le modifiche in sospeso sono in pausa.", + "davUnsupportedServer": "Questo server o profilo provider non è supportato.", + "collectionSettings": "Calendari ed elenchi di attività", + "calendarContent": "Eventi del calendario", + "taskContent": "Attività", + "readOnlySharedCollection": "Sola lettura", + "pendingLocally": "In sospeso localmente", + "conflictBlocked": "Bloccato da un conflitto", + "authenticationBlocked": "Bloccato fino alla riconnessione", + "operationFailed": "Operazione non riuscita", + "keepServerVersion": "Mantieni la versione del server", + "reapplyLocalChange": "Esamina e riapplica la modifica locale", + "duplicateLocalItem": "Duplica come nuovo elemento", "deleteCalendarConfirmation": "Eliminare «{title}»?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "Stato della connessione", + "davConnected": "Connesso", + "davConnecting": "Connessione…", + "davSignedOut": "Disconnesso", + "davLastSuccessfulSync": "Ultima sincronizzazione riuscita: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -427,9 +536,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "Non ancora sincronizzato", + "refreshCollections": "Aggiorna calendari ed elenchi di attività", + "nextcloudServerHost": "Indirizzo server: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -437,12 +546,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "Calendario eventi", + "collectionSupportsTasks": "Elenco di attività", + "collectionSupportsEventsAndTasks": "Eventi e attività", + "writableCollection": "Scrivibile", + "sharedCollection": "Condiviso", + "collectionLastSynced": "Ultima sincronizzazione: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -450,7 +559,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "Problema di sincronizzazione: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -458,8 +567,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "Conflitti di sincronizzazione", + "remoteChangedAt": "Modifica del server: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -467,7 +576,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "Modifica locale: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -475,144 +584,366 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "conflictResolutionFailed": "Impossibile risolvere il conflitto.", + "recurringEventScope": "Ambito dell’evento ricorrente", + "entireSeries": "Intera serie", + "singleOccurrence": "Questo evento", + "thisAndFollowingEvents": "Questo evento e quelli successivi", + "thisAndFutureUnavailable": "Non supportato da questo provider.", + "thisAndFutureMoveUnavailable": "Questo evento e quelli successivi non possono essere spostati in modo sicuro. Scegli questo evento o l’intera serie.", + "entireSeriesMoveUnavailable": "La regola di ricorrenza non è disponibile localmente. Sposta invece solo questo evento.", + "copyEventAndDeleteOriginal": "Copiare l’evento ed eliminare l’originale?", + "copyEventMoveWarning": "BusyMax non può spostare direttamente questo evento da {source} a {destination}. Creerà prima la copia ed eliminerà l’originale solo dopo che la copia sarà riuscita. Gli ID dell’evento cambieranno; gli stati di risposta dei partecipanti potrebbero essere reimpostati e potrebbero essere inviati inviti o annullamenti; i link alle riunioni, gli allegati, i promemoria, i campi specifici del provider e le eccezioni di ricorrenza potrebbero non essere trasferiti.", + "copyAndDelete": "Copia ed elimina", + "chooseRecurringEventScope": "Scegli se questa modifica si applica all’intera serie, solo a questo evento oppure a questo e agli eventi successivi.", "taskDueBeforeStart": "La scadenza non può precedere l'inizio.", - "taskStartDueTimeModeMismatch": "Imposta un orario sia per l'inizio sia per la scadenza, oppure rendi l'attività valida per l'intera giornata.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskStartDueTimeModeMismatch": "Imposta un orario sia per l’inizio sia per la scadenza, oppure rendi l’attività valida per l’intera giornata.", + "taskListCreateFailed": "Impossibile creare l’elenco di attività: {error}", + "taskListRenameFailed": "Impossibile rinominare l’elenco di attività: {error}", + "taskListDeleteFailed": "Impossibile eliminare l’elenco di attività: {error}", "unshare": "Rimuovi condivisione", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "readOnlyTaskListCannotRename": "Questo elenco di attività è di sola lettura e non può essere rinominato.", + "taskListCannotDelete": "Questo elenco di attività non può essere eliminato con le autorizzazioni attuali.", + "deleteTaskListConfirmation": "Eliminare “{title}” e tutte le sue attività?", + "unshareTaskListConfirmation": "Rimuovere la condivisione di “{title}” da questo account?", + "taskStatus": "Stato", + "taskStatusNone": "Nessuno stato", "taskStatusNeedsAction": "Richiede azione", "taskStatusInProcess": "In corso", - "taskStatusCompleted": "Completato", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusCompleted": "Completata", + "taskStatusCancelled": "Annullata", + "completionPercent": "{percent}% completata", + "completionDate": "Data di completamento", "priority": "Priorità", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", + "priorityNone": "Nessuna priorità", + "priorityHighValue": "Priorità {priority} · alta", + "priorityMediumValue": "Priorità {priority} · media", + "priorityLowValue": "Priorità {priority} · bassa", + "taskUrl": "URL dell’attività", + "invalidTaskUrl": "Inserisci un URL assoluto, incluso lo schema.", + "classification": "Classificazione", + "classificationPublic": "Quando condivisa, mostra l’attività completa", + "classificationConfidential": "Quando condivisa, mostra solo lo stato occupato", + "classificationPrivate": "Quando condivisa, nascondi questa attività", + "pinTask": "Fissa attività", + "reminders": "Promemoria", "noReminders": "Nessun promemoria", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Prima dell'inizio dell'attività", - "beforeTaskDue": "Prima della scadenza del compito", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Scarta i promemoria", - "keepRelatedReminders": "Mantieni i promemoria", - "repeatEvery": "Ripeti ogni", - "repeatOn": "Repeat on", + "editReminder": "Modifica promemoria", + "beforeTaskStarts": "Prima dell’inizio dell’attività", + "beforeTaskDue": "Prima della scadenza dell’attività", + "afterTaskStarts": "Dopo l’inizio dell’attività", + "afterTaskDue": "Dopo la scadenza dell’attività", + "relativeToTaskStart": "Rispetto alla data di inizio dell’attività", + "relativeToTaskDue": "Rispetto alla data di scadenza dell’attività", + "reminderTimeOfDay": "Ora del giorno", + "absoluteReminder": "A una data e un’ora", + "reminderAmount": "Quantità", + "reminderUnitSeconds": "Secondi", + "reminderUnitMinutes": "Minuti", + "reminderUnitHours": "Ore", + "reminderUnitDays": "Giorni", + "reminderUnitWeeks": "Settimane", + "reminderAtTaskStart": "All’inizio dell’attività", + "reminderAtTaskDue": "Alla scadenza dell’attività", + "unsupportedReminder": "Questo tipo di promemoria viene conservato, ma non è possibile modificarne l’ora.", + "relatedRemindersTitle": "Conservare i promemoria correlati?", + "relatedRemindersDescription": "Questa data ha {count} promemoria correlati. Conservarli alla data e all’ora attuali?", + "discardRelatedReminders": "Scarta promemoria", + "keepRelatedReminders": "Conserva promemoria", + "repeatEvery": "Intervallo", + "repeatOn": "Ripeti il", "repeatEnd": "Termina ripetizione", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", + "repeatNever": "Mai", + "repeatUntil": "In data", + "repeatAfter": "Dopo un numero di occorrenze", + "repeatCount": "Occorrenze", + "repeatDayOfMonth": "Giorni del mese", + "repeatMonths": "Mesi", + "repeatOrdinal": "Posizione del giorno della settimana", + "repeatSpecificDays": "Giorni specifici", + "repeatFirst": "Primo", + "repeatSecond": "Secondo", + "repeatThird": "Terzo", + "repeatFourth": "Quarto", + "repeatFifth": "Quinto", + "repeatSecondToLast": "Penultimo", + "repeatLast": "Ultimo", + "repeatAnyDay": "Giorno", + "repeatWeekday": "Giorno feriale", + "repeatWeekendDay": "Giorno del fine settimana", + "repeatOrdinalDaySummary": "{dayKey, select, MO{lunedì} TU{martedì} WE{mercoledì} TH{giovedì} FR{venerdì} SA{sabato} SU{domenica} day{giorno} weekday{giorno feriale} weekend{giorno del fine settimana} other{{day}}}", + "repeatEveryDays": "Ogni {count} giorni", + "repeatEveryWeeks": "Ogni {count} settimane", + "repeatEveryMonths": "Ogni {count} mesi", + "repeatEveryYears": "Ogni {count} anni", + "repeatOnDaysSummary": "{days}", "repeatOnMonthDaysSummary": "il giorno {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", - "repeatTimesSummary": "{count} volte", + "repeatOnOrdinalSummary": "{position, select, first{il primo {days}} second{il secondo {days}} third{il terzo {days}} fourth{il quarto {days}} fifth{il quinto {days}} secondToLast{il penultimo {days}} last{l’ultimo {days}} other{nei giorni {days}}}", + "repeatInMonthsSummary": "nei mesi {months}", + "repeatTimesSummary": "{count, plural, one{{count} volta} other{{count} volte}}", "repeatUntilSummary": "fino al {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "unsupportedRecurrencePreserved": "Questa regola di ricorrenza usa opzioni che questo editor non modifica.", + "recurrenceUnsupportedByProvider": "Questa ricorrenza non può essere usata con {provider}.", + "duplicateTask": "Duplica attività", + "taskDuplicated": "Attività duplicata.", + "taskDuplicateFailed": "Impossibile duplicare l’attività: {error}", "hideSubtasks": "Nascondi sottoattività", - "hideClosedSubtasks": "Nascondi sotto-attività chiuse", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", - "networkOffline": "Offline", + "hideClosedSubtasks": "Nascondi sottoattività chiuse", + "reminderUnit": "Unità", + "attendeeRequired": "Obbligatorio", + "attendeeOptional": "Facoltativo", + "meetingSection": "Riunione", + "addGoogleMeet": "Aggiungi Google Meet", + "addTeamsMeeting": "Aggiungi riunione Microsoft Teams", + "onlineMeetingAdded": "Riunione online aggiunta", + "requestResponses": "Richiedi risposte", + "requestResponsesDescription": "Chiedi agli invitati di rispondere all’invito.", + "hideGuestList": "Nascondi elenco invitati", + "hideGuestListDescription": "Gli invitati non possono vedere chi altro è stato invitato.", + "allowNewTimeProposals": "Consenti nuove proposte di orario", + "allowNewTimeProposalsDescription": "Gli invitati possono suggerire un altro orario per la riunione.", + "notifyGuestsTitle": "Notificare gli invitati?", + "notifyGuestsSaveMessage": "Questa riunione ha degli invitati. Inviare inviti o aggiornamenti dell’evento al salvataggio?", + "notifyGuestsDeleteMessage": "Questa riunione ha degli invitati. Inviare una cancellazione quando viene eliminata?", + "sendUpdates": "Invia aggiornamenti", + "sendCancellation": "Invia cancellazione", + "doNotSend": "Non inviare", + "microsoftNotifyGuestsSaveTitle": "Salvare la riunione?", + "microsoftNotifyGuestsSaveMessage": "Microsoft invierà inviti o aggiornamenti dell’evento agli invitati.", + "microsoftNotifyGuestsDeleteTitle": "Eliminare la riunione?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft invierà una cancellazione agli invitati.", + "organizer": "Organizzatore", + "yourResponse": "La tua risposta", + "guestResponses": "Risposte degli invitati", + "respond": "Rispondi", + "acceptInvitation": "Accetta", + "tentativeInvitation": "Provvisorio", + "declineInvitation": "Rifiuta", + "joinMeeting": "Partecipa alla riunione", + "responseAccepted": "Accettata", + "responseTentative": "Provvisoria", + "responseDeclined": "Rifiutata", + "responseNeedsAction": "In attesa di risposta", + "responseNotResponded": "Nessuna risposta", + "responseOrganizer": "Organizzatore", + "invitationResponseFailed": "Impossibile inviare la risposta: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "Impossibile aprire il link della riunione.", + "setCustomCalendarName": "Imposta nome personalizzato", + "setAction": "Imposta", + "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.", + "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.", + "calendarSubscriptions": "Abbonamenti ai calendari", + "calendarSubscriptionsDescription": "Aggiungi calendari di sola lettura che si aggiornano da un URL WebCal sicuro.", + "addCalendarSubscription": "Aggiungi abbonamento al calendario", + "subscriptionName": "Nome locale", + "subscriptionUrl": "URL abbonamento", + "subscriptionUrlHelp": "Inserisci un URL HTTPS o webcal. BusyMax conserva l’URL completo in un archivio sicuro.", + "subscriptionUrlInvalid": "Inserisci un URL HTTPS o webcal valido senza informazioni utente o frammento.", + "subscriptionColor": "Colore locale", + "subscriptionColorHelp": "Usa un colore a sei cifre come #3584E4.", + "subscriptionColorInvalid": "Inserisci un colore esadecimale di sei cifre.", + "subscriptionRefreshMode": "Frequenza di aggiornamento", + "subscriptionAutomatic": "Automatica", + "subscriptionHourly": "Ogni ora", + "subscriptionSixHours": "Ogni sei ore", + "subscriptionDaily": "Ogni giorno", + "subscriptionSafeOrigin": "Origine: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Inserisci un URL valido per visualizzarne l’origine sicura.", + "subscriptionReadOnly": "Abbonamento di sola lettura", + "subscriptionNeverRefreshed": "Non ancora aggiornato", + "subscriptionLastRefresh": "Ultimo aggiornamento riuscito: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Prossimo aggiornamento: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Aggiornato", + "subscriptionStatusIssue": "Problema di aggiornamento: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Aggiorna ora", + "unsubscribe": "Annulla abbonamento", + "unsubscribeCalendarTitle": "Annullare l’abbonamento a “{name}”?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "Rimuove l’abbonamento locale e gli eventi memorizzati nella cache. Il calendario pubblicato non cambia.", + "addSubscriptionAction": "Aggiungi abbonamento", + "subscriptionOperationFailed": "Abbonamento al calendario non riuscito: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Abbonamenti", + "calendarImport": "Importazione calendario", + "calendarImportDescription": "Seleziona un file, rivedi gli eventi e scegli il calendario scrivibile che deve riceverli.", + "importIcsFile": "Importa file .ics", + "importIcsPreview": "Importa eventi del calendario", + "importEventsFound": "Insiemi di eventi importabili: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Eventi non validi: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Ommessi intenzionalmente: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "Nessun calendario di destinazione scrivibile disponibile.", + "importDestinationCalendar": "Calendario di destinazione", + "importIcsConfirm": "Importa eventi", + "importIcsComplete": "Importazione completata", + "importQueued": "Importati o accodati: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Duplicati ignorati: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Insiemi di ricorrenze non supportati: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "Impossibile importare il file del calendario: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "networkOffline": "Senza connessione", "networkOfflineDescription": "Le modifiche verranno sincronizzate al ripristino della connessione.", - "networkOfflineTryAgain": "Sei offline. Connettiti a Internet e riprova." + "networkOfflineTryAgain": "Sei offline. Connettiti a Internet e riprova.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "i giorni {days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first} e {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} e {second}", + "repeatMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} e {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} e {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} il giorno {day} di {month}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} il giorno {day} di {months}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} nei giorni {days} di {months}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{il primo {days} di {months}} second{il secondo {days} di {months}} third{il terzo {days} di {months}} fourth{il quarto {days} di {months}} fifth{il quinto {days} di {months}} secondToLast{il penultimo {days} di {months}} last{l’ultimo {days} di {months}} other{nei giorni {days} di {months}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency} nei giorni {days} di {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{il primo {days} di {month}} second{il secondo {days} di {month}} third{il terzo {days} di {month}} fourth{il quarto {days} di {month}} fifth{il quinto {days} di {month}} secondToLast{il penultimo {days} di {month}} last{l’ultimo {days} di {month}} other{nei giorni {days} di {month}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 3cc802be..ec705fd6 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1,14 +1,31 @@ { "@@locale": "ja", + "windowsSupport": "サポート", + "windowsThirdPartyLicenses": "サードパーティ ライセンス", + "windowsSearch": "検索", + "windowsStartupDisabledByUser": "Windows の設定でユーザーにより無効にされています。", + "windowsStartupDisabledByPolicy": "Windows ポリシーにより無効にされています。", + "windowsStartupUnavailable": "BusyMax を MSIX パッケージからインストールすると利用できます。", + "windowsReminderExitNotice": "BusyMax を完全に終了するとリマインダーも停止します。受け取るにはバックグラウンドで実行したままにしてください。", + "windowsProductVersionLabel": "製品バージョン", + "windowsPackageVersionLabel": "Windows パッケージ バージョン", + "windowsUnpackaged": "パッケージ化されていません", + "windowsAgendaLoadMore": "予定をさらに読み込む", + "repeatWeeklyDaySummary": "{dayKey, select, MO{月曜日} TU{火曜日} WE{水曜日} TH{木曜日} FR{金曜日} SA{土曜日} SU{日曜日} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "{first}、{second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency}{month}の{firstDay}と{secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency}{firstMonth}と{secondMonth}の{day}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency}{firstMonth}と{secondMonth}の{firstDay}と{secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency}{firstMonth}と{secondMonth}の{days}", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Google、Microsoft、Apple iCloud Calendar、または Nextcloud のアカウントを接続します。", "googlePermissionsConsentNotice": "Google の権限画面で、カレンダーとタスクの両方の権限を選択してください。", "googlePermissionsRequiredRetry": "Google カレンダーと Google Tasks の権限が必要です。もう一度試して、両方のチェックボックスを選択してください。", "finishSetup": "セットアップを完了", "continueSetup": "続行", "onboardingSetupTitle": "BusyMax をセットアップ", "onboardingAccountsStepTitle": "アカウントを接続", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "使用するすべてのアカウントを追加します。BusyMax は各アカウントの対応するカレンダー、予定、タスクリスト、タスクを同期します。", "onboardingPreferencesStepTitle": "システム設定を選択", "onboardingPreferencesStepDescription": "スケジュールを開く前に、デスクトップでの動作、リマインダー、通知の詳細度、外観を設定します。", "signInWithGoogle": "Google でサインイン", @@ -33,6 +50,43 @@ "calendarTasks": "タスク", "calendar": "カレンダー", "calendars": "カレンダー", + "newCalendar": "新しいカレンダー", + "calendarColor": "カレンダーの色", + "calendarColorOption": "色 {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "このプロバイダーのカレンダー管理は BusyMax ではサポートされていません。", + "primaryCalendarCannotDelete": "メインカレンダーは削除できません。", + "calendarCreateFailed": "カレンダーを作成できませんでした: {error}", + "calendarCreatedRefreshPending": "カレンダーは作成されましたが、BusyMax はアカウントを更新できませんでした。次回の同期後に表示されます。", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "カレンダーを更新できませんでした: {error}", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "カレンダーを削除できませんでした: {error}", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "新しい予定", "refreshCalendar": "カレンダーを更新", "openInProvider": "サービスで開く", @@ -52,6 +106,55 @@ "scheduleNoSearchResultsDescription": "別の条件で検索するか、現在のフィルターを解除してください。", "refresh": "更新", "trayOpenBusyMax": "BusyMax を開く", + "trayShowBusyMax": "BusyMax を表示", + "trayNewEvent": "新しい予定…", + "trayNewTask": "新しいタスク…", + "trayToday": "今日", + "trayAllDay": "終日", + "trayNow": "今", + "trayCalendarEvent": "カレンダーの予定", + "trayUntitledEvent": "無題の予定", + "trayNothingElseToday": "今日はこれ以上ありません", + "trayTasksDueToday": "{count, plural, =1{今日が期限のタスクは 1 件} other{今日が期限のタスクは {count} 件}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "今日の予定表を開く", + "traySyncNow": "今すぐ同期", + "traySyncing": "同期中…", + "trayNotConnected": "未接続", + "trayNotYetSynced": "まだ同期されていません", + "trayLastSyncedJustNow": "たった今同期", + "trayLastSyncedMinutesAgo": "{count, plural, =1{1 分前に同期} other{{count} 分前に同期}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{1 時間前に同期} other{{count} 時間前に同期}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{1 日前に同期} other{{count} 日前に同期}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "設定", + "trayQuitBusyMax": "BusyMax を終了", "agendaLoadMoreOverdue": "期限切れのタスクをさらに読み込む", "agendaLoadMoreNoDate": "日付のないタスクをさらに読み込む", "viewDay": "日", @@ -154,7 +257,7 @@ "feedbackDetailedMessage": "詳しい内容", "feedbackReplyEmail": "返信先メールアドレス(任意)", "feedbackIncludeTechnicalDetails": "技術情報を含める", - "feedbackTechnicalDetailsDisclosure": "Linux オペレーティングシステムのバージョンとアプリのロケールのみが追加されます。ログ、アカウントデータ、ファイル名、その他の診断情報は含まれません。", + "feedbackTechnicalDetailsDisclosure": "オペレーティングシステムの名前とバージョン、およびアプリのロケールのみが追加されます。ログ、アカウントデータ、ファイル名、その他の診断情報は含まれません。", "feedbackCategoryRequired": "カテゴリーを選択してください。", "feedbackSubjectLengthError": "件名は3文字以上120文字以下にしてください。", "feedbackMessageLengthError": "メッセージは10文字以上5,000文字以下にしてください。", @@ -179,8 +282,8 @@ "removeAccount": "アカウントを削除…", "removingAccount": "アカウントを削除しています…", "removeAccountDescription": "同期を停止し、このアカウントのデータをこのデバイスから削除します。", - "removeAccountTitle": "BusyMax から {account} を削除しますか?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountTitle": "{account} を BusyMax から削除しますか?", + "removeAccountConfirmation": "このデバイスから、キャッシュされたタスク、カレンダー、予定、リマインダー、保留中のオフライン変更を削除します。同期されていない変更は失われます。プロバイダー側のカレンダー、予定、タスクリスト、タスクのコピーは削除されません。", "revokeGoogleAccess": "この Google アカウントへの BusyMax のアクセス権も取り消す", "revokeGoogleAccessDescription": "再接続する前に、もう一度アクセスを許可する必要があります。", "removeAccountAction": "アカウントを削除", @@ -291,7 +394,8 @@ "synced": "同期済み", "account": "アカウント", "sync": "同期", - "manualFullSync": "手動ですべて同期", + "forceFullResync": "完全な再同期を強制", + "forceFullResyncDescription": "接続されているすべてのアカウントからデータを完全に再読み込みします。同期の問題を解決する場合にのみ使用してください。", "runInBackgroundWhenClosed": "ウィンドウを閉じてもバックグラウンドで実行を続ける", "showTrayIcon": "トレイアイコンを表示", "startMinimizedToTray": "トレイに最小化して起動", @@ -305,6 +409,7 @@ "notifyConflicts": "競合時に通知", "notifyDueToday": "今日が期限のタスクを通知", "eventReminders": "予定のリマインダー", + "onState": "オン", "taskReminders": "タスクのリマインダー", "notificationDetailLevel": "通知の詳細度", "notificationDetailPrivate": "非公開", @@ -314,9 +419,13 @@ "quietHoursStart": "通知停止の開始時刻", "quietHoursEnd": "通知停止の終了時刻", "notifications": "通知", + "windowsNotificationsUnavailable": "Windows 通知を利用できません", + "windowsNotificationsUnpackaged": "このパッケージ化されていない開発実行では Windows 通知を使用できません。リマインダーをテストするには、テスト署名済み MSIX をインストールしてください。", + "windowsNotificationsInstalledFailure": "BusyMax は Windows 通知を初期化できませんでした。このインストールの問題が解決するまで、リマインダーは表示されません。", "appearance": "外観", "theme": "テーマ", "themeSystem": "システム", + "settingsSystem": "システム", "themeLight": "ライト", "themeDark": "ダーク", "themeFamily": "テーマファミリー", @@ -376,50 +485,50 @@ "selectTimeZone": "タイムゾーンを選択", "searchLocations": "場所を検索", "noLocationsFound": "場所が見つかりません", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "このフィールドは必須です。", + "providerConnectionDescription": "次のいずれかのプロバイダーからカレンダーとタスクを接続します。", + "appleICloudProvider": "Apple iCloud カレンダー", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloud タスク", + "addAppleICloudAccount": "Apple iCloud カレンダーアカウントを追加", + "addNextcloudAccount": "Nextcloud アカウントを追加", + "waitingForAppleICloud": "Apple iCloud に接続中…", + "waitingForNextcloud": "Nextcloud の認証を待機中…", + "connectAppleICloudTitle": "Apple iCloud カレンダーを接続", + "appleAccountEmail": "Apple アカウントのメールアドレス", + "appleAppSpecificPassword": "App 用パスワード", + "appleAppSpecificPasswordHelp": "Apple アカウントで 2 要素認証を有効にした後、App 用パスワードを作成してください。", + "appleAppSpecificPasswordResetWarning": "Apple アカウントのパスワードをリセットすると、App 用パスワードが無効になります。", + "connectNextcloudTitle": "Nextcloud を接続", + "nextcloudServerUrl": "Nextcloud サーバーまたは CalDAV アドレス", + "nextcloudServerUrlHelp": "Nextcloud サーバーの URL を入力するか、Nextcloud からコピーしたプライマリ CalDAV アドレスを貼り付けてください。", + "nextcloudBrowserAuthorizationHelp": "BusyMax がブラウザーを開きます。そこでアクセスを許可してから BusyMax に戻ってください。", + "connectAccountAction": "接続", + "cancelAccountConnection": "接続をキャンセル", + "nextcloudAccountRemovedRevokeFailed": "アカウントはローカルから削除されましたが、Nextcloud の App パスワードを無効にできませんでした。", + "davCachedOfflineNotice": "オフラインで使用できるよう、カレンダーとタスクのデータはローカルにキャッシュされます。", + "davReauthenticationRequired": "同期を再開するには、このアカウントを再接続してください。", + "davTemporarilyUnavailable": "このアカウントは一時的に利用できません。", + "davPermissionChanged": "サーバーの権限が変更されました。保留中の編集は一時停止されています。", + "davUnsupportedServer": "このサーバーまたはプロバイダー プロファイルはサポートされていません。", + "collectionSettings": "カレンダーとタスクリスト", + "calendarContent": "カレンダーの予定", + "taskContent": "タスク", + "readOnlySharedCollection": "読み取り専用", + "pendingLocally": "ローカルで保留中", + "conflictBlocked": "競合によりブロック", + "authenticationBlocked": "再接続するまでブロック", + "operationFailed": "操作に失敗", + "keepServerVersion": "サーバーのバージョンを保持", + "reapplyLocalChange": "ローカル変更を確認して再適用", + "duplicateLocalItem": "新しいアイテムとして複製", "deleteCalendarConfirmation": "「{title}」を削除しますか?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "接続状態", + "davConnected": "接続済み", + "davConnecting": "接続中…", + "davSignedOut": "サインアウト済み", + "davLastSuccessfulSync": "最後に成功した同期: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -427,9 +536,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "まだ同期されていません", + "refreshCollections": "カレンダーとタスクリストを更新", + "nextcloudServerHost": "サーバー: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -437,12 +546,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "予定カレンダー", + "collectionSupportsTasks": "タスクリスト", + "collectionSupportsEventsAndTasks": "予定とタスク", + "writableCollection": "書き込み可能", + "sharedCollection": "共有", + "collectionLastSynced": "最後の同期: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -450,7 +559,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "同期の問題: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -458,8 +567,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "同期の競合", + "remoteChangedAt": "サーバーでの変更: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -467,7 +576,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "ローカル編集: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -475,144 +584,366 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "conflictResolutionFailed": "競合を解決できませんでした。", + "recurringEventScope": "繰り返し予定の範囲", + "entireSeries": "予定全体", + "singleOccurrence": "この予定", + "thisAndFollowingEvents": "この予定とこれ以降の予定", + "thisAndFutureUnavailable": "このプロバイダーではサポートされていません。", + "thisAndFutureMoveUnavailable": "この予定とこれ以降の予定を安全に移動することはできません。この予定または予定全体を選択してください。", + "entireSeriesMoveUnavailable": "繰り返しルールがローカルにありません。代わりにこの予定だけを移動してください。", + "copyEventAndDeleteOriginal": "予定をコピーして元の予定を削除しますか?", + "copyEventMoveWarning": "BusyMax はこの予定を {source} から {destination} へ直接移動できません。先にコピーを作成し、コピーが成功した場合にのみ元の予定を削除します。予定 ID は変更されます。出席者の回答状況がリセットされ、招待やキャンセルが送信される場合があります。また、会議リンク、添付ファイル、リマインダー、プロバイダー固有のフィールド、繰り返しの例外は引き継がれない場合があります。", + "copyAndDelete": "コピーして削除", + "chooseRecurringEventScope": "この変更を予定全体、この予定のみ、この予定とこれ以降の予定のどれに適用するか選択してください。", "taskDueBeforeStart": "期限を開始より前に設定することはできません。", "taskStartDueTimeModeMismatch": "開始と期限の両方に時刻を設定するか、タスクを終日にしてください。", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskListCreateFailed": "タスクリストを作成できませんでした: {error}", + "taskListRenameFailed": "タスクリストの名前を変更できませんでした: {error}", + "taskListDeleteFailed": "タスクリストを削除できませんでした: {error}", "unshare": "共有を解除", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", - "taskStatusNeedsAction": "アクションが必要", + "readOnlyTaskListCannotRename": "このタスクリストは読み取り専用のため、名前を変更できません。", + "taskListCannotDelete": "現在の権限ではこのタスクリストを削除できません。", + "deleteTaskListConfirmation": "「{title}」とそのすべてのタスクを削除しますか?", + "unshareTaskListConfirmation": "このアカウントとの「{title}」の共有を解除しますか?", + "taskStatus": "ステータス", + "taskStatusNone": "ステータスなし", + "taskStatusNeedsAction": "要対応", "taskStatusInProcess": "進行中", "taskStatusCompleted": "完了", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusCancelled": "キャンセル済み", + "completionPercent": "{percent}% 完了", + "completionDate": "完了日", "priority": "優先度", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "No reminders", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Before the task starts", - "beforeTaskDue": "Before the task is due", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "毎日繰り返す", - "repeatOn": "Repeat on", - "repeatEnd": "繰り返し終了", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", - "repeatOnMonthDaysSummary": "on day {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", - "repeatTimesSummary": "{count}回", - "repeatUntilSummary": "{date}まで", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "priorityNone": "優先度なし", + "priorityHighValue": "優先度 {priority} · 高", + "priorityMediumValue": "優先度 {priority} · 中", + "priorityLowValue": "優先度 {priority} · 低", + "taskUrl": "タスク URL", + "invalidTaskUrl": "スキームを含む絶対 URL を入力してください。", + "classification": "分類", + "classificationPublic": "共有時にタスク全体を表示", + "classificationConfidential": "共有時に空き状況のみ表示", + "classificationPrivate": "共有時にこのタスクを非表示", + "pinTask": "タスクをピン留め", + "reminders": "リマインダー", + "noReminders": "リマインダーなし", + "editReminder": "リマインダーを編集", + "beforeTaskStarts": "タスクの開始前", + "beforeTaskDue": "タスクの期限前", + "afterTaskStarts": "タスクの開始後", + "afterTaskDue": "タスクの期限後", + "relativeToTaskStart": "タスクの開始日に対して", + "relativeToTaskDue": "タスクの期限日に対して", + "reminderTimeOfDay": "時刻", + "absoluteReminder": "日時を指定", + "reminderAmount": "数値", + "reminderUnitSeconds": "秒", + "reminderUnitMinutes": "分", + "reminderUnitHours": "時間", + "reminderUnitDays": "日", + "reminderUnitWeeks": "週", + "reminderAtTaskStart": "タスクの開始時", + "reminderAtTaskDue": "タスクの期限時刻", + "unsupportedReminder": "このリマインダーの種類は保持されますが、時刻は編集できません。", + "relatedRemindersTitle": "関連するリマインダーを保持しますか?", + "relatedRemindersDescription": "この日付には関連するリマインダーが {count} 件あります。現在の日付と時刻を保持しますか?", + "discardRelatedReminders": "リマインダーを破棄", + "keepRelatedReminders": "リマインダーを保持", + "repeatEvery": "繰り返し間隔", + "repeatOn": "繰り返す曜日", + "repeatEnd": "繰り返しを終了", + "repeatNever": "なし", + "repeatUntil": "指定日まで", + "repeatAfter": "指定回数後", + "repeatCount": "繰り返し回数", + "repeatDayOfMonth": "月の日", + "repeatMonths": "月", + "repeatOrdinal": "曜日の順番", + "repeatSpecificDays": "特定の曜日", + "repeatFirst": "第 1", + "repeatSecond": "第 2", + "repeatThird": "第 3", + "repeatFourth": "第 4", + "repeatFifth": "第 5", + "repeatSecondToLast": "最後から 2 番目", + "repeatLast": "最後", + "repeatAnyDay": "日", + "repeatWeekday": "平日", + "repeatWeekendDay": "週末", + "repeatOrdinalDaySummary": "{dayKey, select, MO{月曜日} TU{火曜日} WE{水曜日} TH{木曜日} FR{金曜日} SA{土曜日} SU{日曜日} day{日} weekday{平日} weekend{週末} other{{day}}}", + "repeatEveryDays": "{count} 日ごと", + "repeatEveryWeeks": "{count} 週間ごと", + "repeatEveryMonths": "{count} か月ごと", + "repeatEveryYears": "{count} 年ごと", + "repeatOnDaysSummary": "{days}", + "repeatOnMonthDaysSummary": "{days}", + "repeatOnOrdinalSummary": "{position, select, first{第1{days}} second{第2{days}} third{第3{days}} fourth{第4{days}} fifth{第5{days}} secondToLast{最後から2番目の{days}} last{最後の{days}} other{{days}}}", + "repeatInMonthsSummary": "{months} に", + "repeatTimesSummary": "{count, plural, other{{count} 回}}", + "repeatUntilSummary": "{date} まで", + "unsupportedRecurrencePreserved": "この繰り返しルールには、このエディターで変更されないオプションが含まれています。", + "recurrenceUnsupportedByProvider": "この繰り返しは {provider} では使用できません。", + "duplicateTask": "タスクを複製", + "taskDuplicated": "タスクを複製しました。", + "taskDuplicateFailed": "タスクを複製できませんでした: {error}", "hideSubtasks": "サブタスクを非表示", - "hideClosedSubtasks": "Hide closed subtasks", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "hideClosedSubtasks": "完了したサブタスクを非表示", + "reminderUnit": "単位", + "attendeeRequired": "必須", + "attendeeOptional": "任意", + "meetingSection": "会議", + "addGoogleMeet": "Google Meet を追加", + "addTeamsMeeting": "Microsoft Teams 会議を追加", + "onlineMeetingAdded": "オンライン会議を追加しました", + "requestResponses": "返信をリクエスト", + "requestResponsesDescription": "ゲストに招待への返信を依頼します。", + "hideGuestList": "ゲストリストを非表示", + "hideGuestListDescription": "ゲストには他の招待者が表示されません。", + "allowNewTimeProposals": "新しい日時の提案を許可", + "allowNewTimeProposalsDescription": "ゲストは別の会議時間を提案できます。", + "notifyGuestsTitle": "ゲストに通知しますか?", + "notifyGuestsSaveMessage": "この会議にはゲストがいます。保存時に招待または予定の更新を送信しますか?", + "notifyGuestsDeleteMessage": "この会議にはゲストがいます。削除時にキャンセルを送信しますか?", + "sendUpdates": "更新を送信", + "sendCancellation": "キャンセルを送信", + "doNotSend": "送信しない", + "microsoftNotifyGuestsSaveTitle": "会議を保存しますか?", + "microsoftNotifyGuestsSaveMessage": "Microsoft からゲストに招待または予定の更新が送信されます。", + "microsoftNotifyGuestsDeleteTitle": "会議を削除しますか?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft からゲストにキャンセルが送信されます。", + "organizer": "主催者", + "yourResponse": "自分の返信", + "guestResponses": "ゲストの返信", + "respond": "返信", + "acceptInvitation": "承諾", + "tentativeInvitation": "仮承諾", + "declineInvitation": "辞退", + "joinMeeting": "会議に参加", + "responseAccepted": "承諾済み", + "responseTentative": "仮承諾", + "responseDeclined": "辞退済み", + "responseNeedsAction": "返信待ち", + "responseNotResponded": "未返信", + "responseOrganizer": "主催者", + "invitationResponseFailed": "返信を送信できませんでした: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "会議リンクを開けませんでした。", + "setCustomCalendarName": "カスタム名を設定", + "setAction": "設定", + "removeFromMyCalendars": "マイカレンダーから削除", + "removeAction": "削除", + "removeCalendarConfirmation": "「{title}」を Google カレンダーのリストから削除しますか?共有カレンダーとその予定は削除されません。", + "calendarCannotRemove": "このカレンダーはこのアカウントから削除または解除できません。", + "calendarPendingChangesPreventRemoval": "削除または解除する前に、このカレンダーの保留中の変更が同期されるまで待ってください。", + "calendarSubscriptions": "カレンダーの登録", + "calendarSubscriptionsDescription": "安全な WebCal URL から更新される読み取り専用カレンダーを追加します。", + "addCalendarSubscription": "カレンダー登録を追加", + "subscriptionName": "ローカル名", + "subscriptionUrl": "登録 URL", + "subscriptionUrlHelp": "HTTPS または webcal URL を入力してください。BusyMax は完全な URL を安全なストレージに保存します。", + "subscriptionUrlInvalid": "ユーザー情報やフラグメントを含まない有効な HTTPS または webcal URL を入力してください。", + "subscriptionColor": "ローカルの色", + "subscriptionColorHelp": "#3584E4 のような 6 桁の色を使用してください。", + "subscriptionColorInvalid": "6 桁の 16 進数カラーを入力してください。", + "subscriptionRefreshMode": "更新頻度", + "subscriptionAutomatic": "自動", + "subscriptionHourly": "毎時", + "subscriptionSixHours": "6 時間ごと", + "subscriptionDaily": "毎日", + "subscriptionSafeOrigin": "ソース: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "安全なオリジンをプレビューするには有効な URL を入力してください。", + "subscriptionReadOnly": "読み取り専用の登録", + "subscriptionNeverRefreshed": "まだ更新されていません", + "subscriptionLastRefresh": "最後に成功した更新: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "次回の更新: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "最新", + "subscriptionStatusIssue": "更新の問題: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "今すぐ更新", + "unsubscribe": "登録を解除", + "unsubscribeCalendarTitle": "「{name}」の登録を解除しますか?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "ローカル登録とキャッシュされた予定を削除します。公開されているカレンダーは変更されません。", + "addSubscriptionAction": "登録を追加", + "subscriptionOperationFailed": "カレンダー登録に失敗: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "登録", + "calendarImport": "カレンダーをインポート", + "calendarImportDescription": "ファイルを選択して予定を確認し、受け入れ先の書き込み可能なカレンダーを選択します。", + "importIcsFile": ".ics ファイルをインポート", + "importIcsPreview": "カレンダーの予定をインポート", + "importEventsFound": "インポート可能な予定セット: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "無効な予定: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "意図的に省略: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "書き込み可能な移行先カレンダーがありません。", + "importDestinationCalendar": "移行先カレンダー", + "importIcsConfirm": "予定をインポート", + "importIcsComplete": "インポート完了", + "importQueued": "インポート済みまたはキュー済み: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "重複をスキップ: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "サポートされない繰り返しセット: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "カレンダーファイルをインポートできませんでした: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "オフライン", "networkOfflineDescription": "接続が復旧すると変更が同期されます。", - "networkOfflineTryAgain": "オフラインです。インターネットに接続して、もう一度お試しください。" + "networkOfflineTryAgain": "オフラインです。インターネットに接続して、もう一度お試しください。", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "{days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": "", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}日", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first}、{second}", + "repeatWeekdayListStart": "{first}、{rest}", + "repeatMonthDayListPair": "{first}、{second}", + "repeatMonthDayListStart": "{first}、{rest}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first}と{second}", + "repeatYearlyMonthDayListStart": "{first}、{rest}", + "repeatYearlyMonthListPair": "{first}と{second}", + "repeatYearlyMonthListStart": "{first}、{rest}", + "repeatYearlyOnMonthDaySummary": "{frequency}{month}{day}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency}{months}の{day}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency}{months}の{days}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency}{months}の{position, select, first{第1{days}} second{第2{days}} third{第3{days}} fourth{第4{days}} fifth{第5{days}} secondToLast{最後から2番目の{days}} last{最後の{days}} other{{days}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency}{month}の{days}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency}{month}の{position, select, first{第1{days}} second{第2{days}} third{第3{days}} fourth{第4{days}} fifth{第5{days}} secondToLast{最後から2番目の{days}} last{最後の{days}} other{{days}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 428f5218..e1e69b05 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,14 +1,31 @@ { "@@locale": "ko", + "windowsSupport": "지원", + "windowsThirdPartyLicenses": "타사 라이선스", + "windowsSearch": "검색", + "windowsStartupDisabledByUser": "Windows 설정에서 사용자가 사용하지 않도록 설정했습니다.", + "windowsStartupDisabledByPolicy": "Windows 정책에 의해 사용하지 않도록 설정되었습니다.", + "windowsStartupUnavailable": "BusyMax를 MSIX 패키지로 설치하면 사용할 수 있습니다.", + "windowsReminderExitNotice": "BusyMax를 완전히 종료하면 미리 알림이 중지됩니다. 받으려면 백그라운드에서 계속 실행하세요.", + "windowsProductVersionLabel": "제품 버전", + "windowsPackageVersionLabel": "Windows 패키지 버전", + "windowsUnpackaged": "패키지되지 않음", + "windowsAgendaLoadMore": "일정 항목 더 불러오기", + "repeatWeeklyDaySummary": "{dayKey, select, MO{월요일} TU{화요일} WE{수요일} TH{목요일} FR{금요일} SA{토요일} SU{일요일} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "{first}과 {second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} {month}의 {firstDay}과 {secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} {firstMonth}과 {secondMonth}의 {day}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} {firstMonth}과 {secondMonth}의 {firstDay}과 {secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} {firstMonth}과 {secondMonth}의 {days}", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Google, Microsoft, Apple iCloud Calendar 또는 Nextcloud 계정을 연결하세요.", "googlePermissionsConsentNotice": "Google 권한 화면에서 캘린더와 할 일 권한을 모두 선택하세요.", "googlePermissionsRequiredRetry": "Google Calendar 및 Google Tasks 권한이 필요합니다. 다시 시도하여 두 체크박스를 모두 선택하세요.", "finishSetup": "설정 완료", "continueSetup": "계속", "onboardingSetupTitle": "BusyMax 설정", "onboardingAccountsStepTitle": "계정 연결", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "사용할 모든 계정을 추가하세요. BusyMax는 각 계정의 지원되는 캘린더, 일정, 할 일 목록 및 할 일을 동기화합니다.", "onboardingPreferencesStepTitle": "시스템 설정 선택", "onboardingPreferencesStepDescription": "일정을 열기 전에 데스크톱 동작, 미리 알림, 알림 세부 수준 및 화면 모양을 설정하세요.", "signInWithGoogle": "Google로 로그인", @@ -33,6 +50,43 @@ "calendarTasks": "할 일", "calendar": "캘린더", "calendars": "캘린더", + "newCalendar": "새 캘린더", + "calendarColor": "캘린더 색상", + "calendarColorOption": "색상 {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "이 공급자는 BusyMax에서 캘린더 관리를 지원하지 않습니다.", + "primaryCalendarCannotDelete": "기본 캘린더는 삭제할 수 없습니다.", + "calendarCreateFailed": "캘린더를 만들 수 없습니다: {error}", + "calendarCreatedRefreshPending": "캘린더가 생성되었지만 BusyMax에서 계정을 새로 고치지 못했습니다. 다음 동기화 후에 표시됩니다.", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "캘린더를 업데이트할 수 없습니다: {error}", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "캘린더를 삭제할 수 없습니다: {error}", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "새 일정", "refreshCalendar": "캘린더 새로 고침", "openInProvider": "서비스에서 열기", @@ -52,6 +106,55 @@ "scheduleNoSearchResultsDescription": "다른 검색어를 사용하거나 현재 필터를 지우세요.", "refresh": "새로 고침", "trayOpenBusyMax": "BusyMax 열기", + "trayShowBusyMax": "BusyMax 표시", + "trayNewEvent": "새 일정…", + "trayNewTask": "새 할 일…", + "trayToday": "오늘", + "trayAllDay": "종일", + "trayNow": "지금", + "trayCalendarEvent": "캘린더 일정", + "trayUntitledEvent": "제목 없는 일정", + "trayNothingElseToday": "오늘은 더 이상 없음", + "trayTasksDueToday": "{count, plural, =1{오늘 마감인 할 일 1개} other{오늘 마감인 할 일 {count}개}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "오늘 일정 열기", + "traySyncNow": "지금 동기화", + "traySyncing": "동기화 중…", + "trayNotConnected": "연결되지 않음", + "trayNotYetSynced": "아직 동기화되지 않음", + "trayLastSyncedJustNow": "방금 동기화됨", + "trayLastSyncedMinutesAgo": "{count, plural, =1{1분 전에 동기화됨} other{{count}분 전에 동기화됨}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{1시간 전에 동기화됨} other{{count}시간 전에 동기화됨}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{1일 전에 동기화됨} other{{count}일 전에 동기화됨}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "설정", + "trayQuitBusyMax": "BusyMax 종료", "agendaLoadMoreOverdue": "기한이 지난 할 일 더 불러오기", "agendaLoadMoreNoDate": "날짜 없는 할 일 더 불러오기", "viewDay": "일", @@ -154,7 +257,7 @@ "feedbackDetailedMessage": "자세한 내용", "feedbackReplyEmail": "답변 받을 이메일 주소(선택 사항)", "feedbackIncludeTechnicalDetails": "기술 세부 정보 포함", - "feedbackTechnicalDetailsDisclosure": "Linux 운영 체제 버전과 앱 로캘만 추가됩니다. 로그, 계정 데이터, 파일 이름 또는 기타 진단 정보는 포함되지 않습니다.", + "feedbackTechnicalDetailsDisclosure": "운영 체제 이름과 버전 및 앱 로캘만 추가됩니다. 로그, 계정 데이터, 파일 이름 또는 기타 진단 정보는 포함되지 않습니다.", "feedbackCategoryRequired": "범주를 선택하세요.", "feedbackSubjectLengthError": "제목은 3~120자여야 합니다.", "feedbackMessageLengthError": "메시지는 10~5,000자여야 합니다.", @@ -179,8 +282,8 @@ "removeAccount": "계정 삭제…", "removingAccount": "계정 삭제 중…", "removeAccountDescription": "동기화를 중지하고 이 기기에서 이 계정의 데이터를 삭제합니다.", - "removeAccountTitle": "BusyMax에서 {account} 계정을 삭제할까요?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountTitle": "{account}을(를) BusyMax에서 삭제할까요?", + "removeAccountConfirmation": "이 작업은 이 기기에서 캐시된 할 일, 캘린더, 일정, 알림 및 보류 중인 오프라인 변경사항을 삭제합니다. 동기화되지 않은 변경사항은 손실됩니다. 제공업체의 캘린더, 일정, 할 일 목록 및 할 일 사본은 삭제되지 않습니다.", "revokeGoogleAccess": "이 Google 계정에 대한 BusyMax의 액세스 권한도 취소", "revokeGoogleAccessDescription": "다시 연결하기 전에 액세스 권한을 다시 부여해야 합니다.", "removeAccountAction": "계정 삭제", @@ -196,7 +299,7 @@ "deleteList": "목록 삭제", "builtInMicrosoftList": "기본 제공", "builtInMicrosoftListCannotRenameDelete": "Microsoft To Do의 기본 제공 목록은 이름을 바꾸거나 삭제할 수 없습니다.", - "deleteListConfirmation": "Google Tasks에서 “{title}” 목록을 삭제할까요?", + "deleteListConfirmation": "Google Tasks에서 “{title}”을(를) 삭제할까요?", "deleteEvent": "일정 삭제", "title": "제목", "create": "만들기", @@ -245,7 +348,7 @@ "reminderDate": "미리 알림 날짜", "reminderTime": "미리 알림 시간", "reminder": "미리 알림", - "addReminder": "미리 알림 추가", + "addReminder": "알림 추가", "addGuest": "참석자 추가", "addGuestEmail": "참석자 이메일 추가", "removeReminder": "미리 알림 삭제", @@ -291,7 +394,8 @@ "synced": "동기화됨", "account": "계정", "sync": "동기화", - "manualFullSync": "수동 전체 동기화", + "forceFullResync": "전체 재동기화 강제 실행", + "forceFullResyncDescription": "연결된 모든 계정의 데이터를 완전히 다시 불러옵니다. 동기화 문제를 해결할 때만 사용하세요.", "runInBackgroundWhenClosed": "창을 닫아도 계속 실행", "showTrayIcon": "트레이 아이콘 표시", "startMinimizedToTray": "트레이에 최소화하여 시작", @@ -305,6 +409,7 @@ "notifyConflicts": "충돌 알림", "notifyDueToday": "오늘 마감인 할 일 알림", "eventReminders": "일정 미리 알림", + "onState": "켬", "taskReminders": "할 일 미리 알림", "notificationDetailLevel": "알림 세부 수준", "notificationDetailPrivate": "비공개", @@ -314,9 +419,13 @@ "quietHoursStart": "방해 금지 시작 시간", "quietHoursEnd": "방해 금지 종료 시간", "notifications": "알림", + "windowsNotificationsUnavailable": "Windows 알림을 사용할 수 없음", + "windowsNotificationsUnpackaged": "패키징되지 않은 이 개발 실행에서는 Windows 알림을 사용할 수 없습니다. 미리 알림을 테스트하려면 테스트 서명된 MSIX를 설치하세요.", + "windowsNotificationsInstalledFailure": "BusyMax에서 Windows 알림을 초기화하지 못했습니다. 이 설치 문제가 해결될 때까지 미리 알림이 표시되지 않습니다.", "appearance": "화면 모양", "theme": "테마", "themeSystem": "시스템", + "settingsSystem": "시스템", "themeLight": "라이트", "themeDark": "다크", "themeFamily": "테마 계열", @@ -352,7 +461,7 @@ "syncFailureNotificationTitle": "BusyMax 동기화 실패", "syncFailureNotificationBody": "백그라운드 동기화에 실패했습니다. {message}", "conflictNotificationTitle": "BusyMax 동기화 충돌", - "conflictNotificationBody": "보류 중인 로컬 변경 사항이 차단되었습니다. {summary}", + "conflictNotificationBody": "보류 중인 로컬 변경사항이 차단되었습니다. {summary}", "dueTodayNotificationTitle": "오늘 마감인 할 일", "dueTodayNotificationBody": "{count, plural, =1{오늘 마감인 할 일이 1개 있습니다.} other{오늘 마감인 할 일이 {count}개 있습니다.}}", "eventReminderNotificationTitle": "일정 미리 알림", @@ -361,7 +470,7 @@ "taskReminderNotificationBody": "할 일 마감이 얼마 남지 않았습니다.", "notificationOpenAction": "열기", "notificationSnoozeAction": "10분 후 다시 알림", - "notificationDismissAction": "해제", + "notificationDismissAction": "닫기", "notificationDetailsHidden": "개인정보 보호 설정에 따라 세부 정보가 숨겨졌습니다.", "previousMonth": "이전 달", "nextMonth": "다음 달", @@ -375,51 +484,51 @@ "readOnlyCalendar": "이 캘린더는 읽기 전용입니다.", "selectTimeZone": "시간대 선택", "searchLocations": "위치 검색", - "noLocationsFound": "위치를 찾을 수 없습니다", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "noLocationsFound": "위치를 찾을 수 없음", + "requiredField": "이 필드는 필수입니다.", + "providerConnectionDescription": "다음 제공업체 중 하나에서 캘린더와 할 일을 연결하세요.", + "appleICloudProvider": "Apple iCloud 캘린더", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloud 할 일", + "addAppleICloudAccount": "Apple iCloud 캘린더 계정 추가", + "addNextcloudAccount": "Nextcloud 계정 추가", + "waitingForAppleICloud": "Apple iCloud에 연결 중…", + "waitingForNextcloud": "Nextcloud 인증 대기 중…", + "connectAppleICloudTitle": "Apple iCloud 캘린더 연결", + "appleAccountEmail": "Apple 계정 이메일", + "appleAppSpecificPassword": "앱 전용 암호", + "appleAppSpecificPasswordHelp": "Apple 계정에서 이중 인증을 활성화한 후 앱 전용 암호를 생성하세요.", + "appleAppSpecificPasswordResetWarning": "Apple 계정 암호를 재설정하면 앱 전용 암호가 취소됩니다.", + "connectNextcloudTitle": "Nextcloud 연결", + "nextcloudServerUrl": "Nextcloud 서버 또는 CalDAV 주소", + "nextcloudServerUrlHelp": "Nextcloud 서버 URL을 입력하거나 Nextcloud에서 복사한 기본 CalDAV 주소를 붙여넣으세요.", + "nextcloudBrowserAuthorizationHelp": "BusyMax가 브라우저를 엽니다. 그곳에서 액세스를 승인한 다음 BusyMax로 돌아오세요.", + "connectAccountAction": "연결", + "cancelAccountConnection": "연결 취소", + "nextcloudAccountRemovedRevokeFailed": "계정은 로컬에서 삭제되었지만 Nextcloud 앱 암호를 취소할 수 없습니다.", + "davCachedOfflineNotice": "오프라인 사용을 위해 캘린더 및 할 일 데이터가 로컬에 캐시됩니다.", + "davReauthenticationRequired": "동기화를 재개하려면 이 계정을 다시 연결하세요.", + "davTemporarilyUnavailable": "이 계정은 일시적으로 사용할 수 없습니다.", + "davPermissionChanged": "서버 권한이 변경되었습니다. 보류 중인 편집이 일시 중지되었습니다.", + "davUnsupportedServer": "이 서버 또는 제공업체 프로필은 지원되지 않습니다.", + "collectionSettings": "캘린더 및 할 일 목록", + "calendarContent": "캘린더 일정", + "taskContent": "할 일", + "readOnlySharedCollection": "읽기 전용", + "pendingLocally": "로컬에서 보류 중", + "conflictBlocked": "충돌로 차단됨", + "authenticationBlocked": "다시 연결할 때까지 차단됨", + "operationFailed": "작업 실패", + "keepServerVersion": "서버 버전 유지", + "reapplyLocalChange": "로컬 변경사항 검토 후 다시 적용", + "duplicateLocalItem": "새 항목으로 복제", "deleteCalendarConfirmation": "“{title}” 캘린더를 삭제할까요?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "연결 상태", + "davConnected": "연결됨", + "davConnecting": "연결 중…", + "davSignedOut": "로그아웃됨", + "davLastSuccessfulSync": "마지막으로 성공한 동기화: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -427,9 +536,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "아직 동기화되지 않음", + "refreshCollections": "캘린더 및 할 일 목록 새로 고침", + "nextcloudServerHost": "서버: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -437,12 +546,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "일정 캘린더", + "collectionSupportsTasks": "할 일 목록", + "collectionSupportsEventsAndTasks": "일정 및 할 일", + "writableCollection": "쓰기 가능", + "sharedCollection": "공유됨", + "collectionLastSynced": "마지막 동기화: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -450,7 +559,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "동기화 문제: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -458,8 +567,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "동기화 충돌", + "remoteChangedAt": "서버 변경: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -467,7 +576,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "로컬 편집: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -475,144 +584,366 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "conflictResolutionFailed": "충돌을 해결할 수 없습니다.", + "recurringEventScope": "반복 일정 범위", + "entireSeries": "전체 시리즈", + "singleOccurrence": "이 일정", + "thisAndFollowingEvents": "이 일정 및 향후 일정", + "thisAndFutureUnavailable": "이 제공업체에서는 지원되지 않습니다.", + "thisAndFutureMoveUnavailable": "이 일정과 이후 일정을 안전하게 이동할 수 없습니다. 이 일정 또는 전체 반복 일정을 선택하세요.", + "entireSeriesMoveUnavailable": "반복 규칙을 로컬에서 사용할 수 없습니다. 이 일정만 이동하세요.", + "copyEventAndDeleteOriginal": "일정을 복사하고 원본을 삭제할까요?", + "copyEventMoveWarning": "BusyMax는 이 일정을 {source}에서 {destination}(으)로 직접 이동할 수 없습니다. 먼저 복사본을 만들고 복사가 성공한 뒤에만 원본을 삭제합니다. 일정 ID가 변경되고 참석자 응답 상태가 초기화되며 초대 또는 취소가 전송될 수 있습니다. 회의 링크, 첨부 파일, 미리 알림, 제공업체별 필드 및 반복 예외는 이전되지 않을 수 있습니다.", + "copyAndDelete": "복사 후 삭제", + "chooseRecurringEventScope": "이 변경을 전체 시리즈, 이 일정만 또는 이 일정과 이후 일정에 적용할지 선택하세요.", "taskDueBeforeStart": "마감은 시작보다 빠를 수 없습니다.", "taskStartDueTimeModeMismatch": "시작과 마감에 모두 시간을 설정하거나 작업을 종일로 설정하세요.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskListCreateFailed": "할 일 목록을 만들 수 없습니다: {error}", + "taskListRenameFailed": "할 일 목록 이름을 바꿀 수 없습니다: {error}", + "taskListDeleteFailed": "할 일 목록을 삭제할 수 없습니다: {error}", "unshare": "공유 해제", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", - "taskStatusNeedsAction": "Needs action", - "taskStatusInProcess": "In process", - "taskStatusCompleted": "완료됨", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", - "priority": "우선 순위", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "No reminders", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Before the task starts", - "beforeTaskDue": "Before the task is due", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "반복 주기", - "repeatOn": "Repeat on", + "readOnlyTaskListCannotRename": "이 할 일 목록은 읽기 전용이므로 이름을 바꿀 수 없습니다.", + "taskListCannotDelete": "현재 권한으로는 이 할 일 목록을 삭제할 수 없습니다.", + "deleteTaskListConfirmation": "“{title}” 및 모든 할 일을 삭제할까요?", + "unshareTaskListConfirmation": "이 계정에서 “{title}” 공유를 해제할까요?", + "taskStatus": "상태", + "taskStatusNone": "상태 없음", + "taskStatusNeedsAction": "조치 필요", + "taskStatusInProcess": "진행 중", + "taskStatusCompleted": "완료", + "taskStatusCancelled": "취소됨", + "completionPercent": "{percent}% 완료", + "completionDate": "완료 날짜", + "priority": "우선순위", + "priorityNone": "우선순위 없음", + "priorityHighValue": "우선순위 {priority} · 높음", + "priorityMediumValue": "우선순위 {priority} · 중간", + "priorityLowValue": "우선순위 {priority} · 낮음", + "taskUrl": "할 일 URL", + "invalidTaskUrl": "스킴을 포함한 절대 URL을 입력하세요.", + "classification": "분류", + "classificationPublic": "공유할 때 전체 할 일 표시", + "classificationConfidential": "공유할 때 바쁨 상태만 표시", + "classificationPrivate": "공유할 때 이 할 일 숨기기", + "pinTask": "할 일 고정", + "reminders": "알림", + "noReminders": "알림 없음", + "editReminder": "알림 수정", + "beforeTaskStarts": "할 일 시작 전", + "beforeTaskDue": "할 일 마감 전", + "afterTaskStarts": "할 일 시작 후", + "afterTaskDue": "할 일 마감 후", + "relativeToTaskStart": "할 일 시작일 기준", + "relativeToTaskDue": "할 일 마감일 기준", + "reminderTimeOfDay": "시간", + "absoluteReminder": "날짜 및 시간에", + "reminderAmount": "수량", + "reminderUnitSeconds": "초", + "reminderUnitMinutes": "분", + "reminderUnitHours": "시간", + "reminderUnitDays": "일", + "reminderUnitWeeks": "주", + "reminderAtTaskStart": "할 일 시작 시", + "reminderAtTaskDue": "할 일 마감 시", + "unsupportedReminder": "이 알림 유형은 유지되지만 시간을 수정할 수 없습니다.", + "relatedRemindersTitle": "관련 알림을 유지할까요?", + "relatedRemindersDescription": "이 날짜에 관련 알림이 {count}개 있습니다. 현재 날짜와 시간에 유지할까요?", + "discardRelatedReminders": "알림 삭제", + "keepRelatedReminders": "알림 유지", + "repeatEvery": "반복 간격", + "repeatOn": "반복 요일", "repeatEnd": "반복 종료", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", - "repeatOnMonthDaysSummary": "on day {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", - "repeatTimesSummary": "{count}회", - "repeatUntilSummary": "until {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", - "hideSubtasks": "Hide subtasks", - "hideClosedSubtasks": "Hide closed subtasks", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "repeatNever": "안 함", + "repeatUntil": "날짜까지", + "repeatAfter": "지정한 횟수 후", + "repeatCount": "반복 횟수", + "repeatDayOfMonth": "월의 날짜", + "repeatMonths": "월", + "repeatOrdinal": "요일 순서", + "repeatSpecificDays": "특정 요일", + "repeatFirst": "첫 번째", + "repeatSecond": "두 번째", + "repeatThird": "세 번째", + "repeatFourth": "네 번째", + "repeatFifth": "다섯 번째", + "repeatSecondToLast": "끝에서 두 번째", + "repeatLast": "마지막", + "repeatAnyDay": "요일", + "repeatWeekday": "평일", + "repeatWeekendDay": "주말", + "repeatOrdinalDaySummary": "{dayKey, select, MO{월요일} TU{화요일} WE{수요일} TH{목요일} FR{금요일} SA{토요일} SU{일요일} day{날} weekday{평일} weekend{주말} other{{day}}}", + "repeatEveryDays": "{count}일마다", + "repeatEveryWeeks": "{count}주마다", + "repeatEveryMonths": "{count}개월마다", + "repeatEveryYears": "{count}년마다", + "repeatOnDaysSummary": "{days}에", + "repeatOnMonthDaysSummary": "{days}", + "repeatOnOrdinalSummary": "{position, select, first{첫 번째 {days}} second{두 번째 {days}} third{세 번째 {days}} fourth{네 번째 {days}} fifth{다섯 번째 {days}} secondToLast{끝에서 두 번째 {days}} last{마지막 {days}} other{{days}}}", + "repeatInMonthsSummary": "{months}에", + "repeatTimesSummary": "{count, plural, other{{count}회}}", + "repeatUntilSummary": "{date}까지", + "unsupportedRecurrencePreserved": "이 반복 규칙에는 이 편집기에서 변경하지 않는 옵션이 사용됩니다.", + "recurrenceUnsupportedByProvider": "이 반복은 {provider}에서 사용할 수 없습니다.", + "duplicateTask": "할 일 복제", + "taskDuplicated": "할 일이 복제되었습니다.", + "taskDuplicateFailed": "할 일을 복제할 수 없습니다: {error}", + "hideSubtasks": "하위 할 일 숨기기", + "hideClosedSubtasks": "완료된 하위 할 일 숨기기", + "reminderUnit": "단위", + "attendeeRequired": "필수", + "attendeeOptional": "선택사항", + "meetingSection": "회의", + "addGoogleMeet": "Google Meet 추가", + "addTeamsMeeting": "Microsoft Teams 회의 추가", + "onlineMeetingAdded": "온라인 회의가 추가됨", + "requestResponses": "응답 요청", + "requestResponsesDescription": "참석자에게 초대에 응답하도록 요청합니다.", + "hideGuestList": "참석자 목록 숨기기", + "hideGuestListDescription": "참석자는 다른 초대 대상자를 볼 수 없습니다.", + "allowNewTimeProposals": "새 시간 제안 허용", + "allowNewTimeProposalsDescription": "참석자가 다른 회의 시간을 제안할 수 있습니다.", + "notifyGuestsTitle": "참석자에게 알릴까요?", + "notifyGuestsSaveMessage": "이 회의에는 참석자가 있습니다. 저장할 때 초대 또는 일정 업데이트를 보낼까요?", + "notifyGuestsDeleteMessage": "이 회의에는 참석자가 있습니다. 삭제할 때 취소를 보낼까요?", + "sendUpdates": "업데이트 보내기", + "sendCancellation": "취소 보내기", + "doNotSend": "보내지 않음", + "microsoftNotifyGuestsSaveTitle": "회의를 저장할까요?", + "microsoftNotifyGuestsSaveMessage": "Microsoft에서 참석자에게 초대 또는 일정 업데이트를 보냅니다.", + "microsoftNotifyGuestsDeleteTitle": "회의를 삭제할까요?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft에서 참석자에게 취소를 보냅니다.", + "organizer": "주최자", + "yourResponse": "내 응답", + "guestResponses": "참석자 응답", + "respond": "응답", + "acceptInvitation": "수락", + "tentativeInvitation": "미정", + "declineInvitation": "거절", + "joinMeeting": "회의 참가", + "responseAccepted": "수락됨", + "responseTentative": "미정", + "responseDeclined": "거절됨", + "responseNeedsAction": "응답 대기 중", + "responseNotResponded": "응답하지 않음", + "responseOrganizer": "주최자", + "invitationResponseFailed": "응답을 보낼 수 없습니다: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "회의 링크를 열 수 없습니다.", + "setCustomCalendarName": "맞춤 이름 설정", + "setAction": "설정", + "removeFromMyCalendars": "내 캘린더에서 삭제", + "removeAction": "삭제", + "removeCalendarConfirmation": "Google Calendar 목록에서 \"{title}\"을(를) 삭제할까요? 공유 캘린더와 일정은 삭제되지 않습니다.", + "calendarCannotRemove": "이 캘린더는 이 계정에서 삭제하거나 제거할 수 없습니다.", + "calendarPendingChangesPreventRemoval": "삭제하거나 제거하기 전에 이 캘린더의 보류 중인 변경사항이 동기화될 때까지 기다리세요.", + "calendarSubscriptions": "캘린더 구독", + "calendarSubscriptionsDescription": "보안 WebCal URL에서 새로 고침되는 읽기 전용 캘린더를 추가하세요.", + "addCalendarSubscription": "캘린더 구독 추가", + "subscriptionName": "로컬 이름", + "subscriptionUrl": "구독 URL", + "subscriptionUrlHelp": "HTTPS 또는 webcal URL을 입력하세요. BusyMax는 전체 URL을 안전한 저장소에 보관합니다.", + "subscriptionUrlInvalid": "사용자 정보나 프래그먼트가 없는 올바른 HTTPS 또는 webcal URL을 입력하세요.", + "subscriptionColor": "로컬 색상", + "subscriptionColorHelp": "#3584E4와 같은 6자리 색상을 사용하세요.", + "subscriptionColorInvalid": "6자리 16진수 색상을 입력하세요.", + "subscriptionRefreshMode": "새로 고침 빈도", + "subscriptionAutomatic": "자동", + "subscriptionHourly": "매시간", + "subscriptionSixHours": "6시간마다", + "subscriptionDaily": "매일", + "subscriptionSafeOrigin": "소스: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "안전한 원본을 미리 보려면 올바른 URL을 입력하세요.", + "subscriptionReadOnly": "읽기 전용 구독", + "subscriptionNeverRefreshed": "아직 새로 고치지 않음", + "subscriptionLastRefresh": "마지막으로 성공한 새로 고침: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "다음 새로 고침: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "최신 상태", + "subscriptionStatusIssue": "새로 고침 문제: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "지금 새로 고침", + "unsubscribe": "구독 취소", + "unsubscribeCalendarTitle": "“{name}” 구독을 취소할까요?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "로컬 구독과 캐시된 일정을 제거합니다. 게시된 캘린더는 변경되지 않습니다.", + "addSubscriptionAction": "구독 추가", + "subscriptionOperationFailed": "캘린더 구독 실패: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "구독", + "calendarImport": "캘린더 가져오기", + "calendarImportDescription": "파일을 선택하고 일정을 검토한 다음 이를 받을 쓰기 가능한 캘린더를 선택하세요.", + "importIcsFile": ".ics 파일 가져오기", + "importIcsPreview": "캘린더 일정 가져오기", + "importEventsFound": "가져올 수 있는 일정 세트: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "잘못된 일정: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "의도적으로 생략됨: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "쓰기 가능한 대상 캘린더가 없습니다.", + "importDestinationCalendar": "대상 캘린더", + "importIcsConfirm": "일정 가져오기", + "importIcsComplete": "가져오기 완료", + "importQueued": "가져왔거나 대기열에 추가됨: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "중복 건너뜀: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "지원되지 않는 반복 세트: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "캘린더 파일을 가져올 수 없습니다: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "오프라인", "networkOfflineDescription": "연결이 복구되면 변경 사항이 동기화됩니다.", - "networkOfflineTryAgain": "오프라인 상태입니다. 인터넷에 연결한 후 다시 시도하세요." + "networkOfflineTryAgain": "오프라인 상태입니다. 인터넷에 연결한 후 다시 시도하세요.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "{days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}일", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first}과 {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first}과 {second}", + "repeatMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first}과 {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first}과 {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} {month} {day}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} {months}의 {day}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} {months}의 {days}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {months}의 {position, select, first{첫 번째 {days}} second{두 번째 {days}} third{세 번째 {days}} fourth{네 번째 {days}} fifth{다섯 번째 {days}} secondToLast{끝에서 두 번째 {days}} last{마지막 {days}} other{{days}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency} {month}의 {days}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {month}의 {position, select, first{첫 번째 {days}} second{두 번째 {days}} third{세 번째 {days}} fourth{네 번째 {days}} fifth{다섯 번째 {days}} secondToLast{끝에서 두 번째 {days}} last{마지막 {days}} other{{days}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 7c52989c..66838741 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1,618 +1,3 @@ { - "@@locale": "pt", - "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", - "googlePermissionsConsentNotice": "No ecrã de autorizações da Google, selecione as autorizações do Calendário e das Tarefas.", - "googlePermissionsRequiredRetry": "As autorizações do Calendário Google e do Google Tasks são necessárias. Tente novamente e selecione ambas as caixas.", - "finishSetup": "Concluir configuração", - "continueSetup": "Continuar", - "onboardingSetupTitle": "Configurar o BusyMax", - "onboardingAccountsStepTitle": "Ligar contas", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", - "onboardingPreferencesStepTitle": "Escolher definições do sistema", - "onboardingPreferencesStepDescription": "Configure o comportamento da aplicação no ambiente de trabalho, os lembretes, o nível de detalhe das notificações e o aspeto antes de abrir a agenda.", - "signInWithGoogle": "Iniciar sessão com a Google", - "signInWithMicrosoft": "Iniciar sessão com a Microsoft", - "googleTasksProvider": "Google Tasks", - "microsoftTodoProvider": "Microsoft To Do", - "providerNotConfigured": "Este fornecedor não está configurado.", - "waitingForGoogleSignIn": "A aguardar o início de sessão da Google...", - "waitingForMicrosoftSignIn": "A aguardar o início de sessão da Microsoft...", - "microsoftSignInNotConfigured": "O início de sessão da Microsoft não está configurado. Defina MICROSOFT_OAUTH_CLIENT_ID.", - "cancel": "Cancelar", - "close": "Fechar", - "exit": "Sair", - "options": "Opções", - "hide": "Ocultar", - "show": "Mostrar", - "export": "Exportar", - "save": "Guardar", - "settings": "Definições", - "all": "Tudo", - "calendarEvents": "Eventos", - "calendarTasks": "Tarefas", - "calendar": "Calendário", - "calendars": "Calendários", - "newEvent": "Novo evento", - "refreshCalendar": "Atualizar calendário", - "openInProvider": "Abrir no serviço", - "hideFromSchedule": "Ocultar da agenda", - "showInSchedule": "Mostrar na agenda", - "noCalendarsSynced": "Ainda não há calendários sincronizados.", - "allDay": "Todo o dia", - "moreItems": "+{count} mais", - "noEventsOrTasks": "Sem eventos ou tarefas", - "scheduleLoading": "A carregar agenda...", - "scheduleUnavailable": "Agenda indisponível", - "scheduleNoSources": "Sem calendários ou listas de tarefas visíveis", - "scheduleNoSourcesDescription": "Escolha o que pretende mostrar nas Definições e atualize a agenda.", - "scheduleSignInRequired": "Ligar uma conta", - "scheduleSignInDescription": "Inicie sessão para sincronizar calendários e tarefas.", - "scheduleNoSearchResults": "Nenhum evento ou tarefa correspondente", - "scheduleNoSearchResultsDescription": "Experimente outra pesquisa ou limpe os filtros atuais.", - "refresh": "Atualizar", - "trayOpenBusyMax": "Abrir o BusyMax", - "agendaLoadMoreOverdue": "Carregar mais tarefas em atraso", - "agendaLoadMoreNoDate": "Carregar mais tarefas sem data", - "viewDay": "Dia", - "viewWeek": "Semana", - "viewMonth": "Mês", - "viewYear": "Ano", - "viewAgenda": "Agenda", - "scheduleSettings": "Agenda", - "scheduleDisplaySettings": "Apresentação da agenda", - "scheduleDisplayHoursDescription": "As vistas de dia e semana mostram inicialmente este intervalo horário. Os itens anteriores ou posteriores alargam-no quando necessário.", - "scheduleDayStartsAt": "O dia começa às", - "scheduleDayEndsAt": "O dia termina às", - "sourceCalendar": "Calendário", - "sourceTaskList": "Lista de tarefas", - "createChoiceTitle": "Criar", - "createEventAtTime": "Evento", - "createTaskAtDate": "Tarefa", - "editEvent": "Editar evento", - "eventTitle": "Título do evento", - "location": "Local", - "timeSlot": "Intervalo de tempo", - "startDateTime": "Data e hora de início", - "endDateTime": "Data e hora de fim", - "doesNotRepeat": "Não se repete", - "defaultReminder": "Lembrete predefinido", - "guests": "Convidados", - "noGuests": "Sem convidados", - "description": "Descrição", - "availabilityShowAs": "Disponibilidade / Mostrar como", - "busy": "Ocupado", - "visibility": "Visibilidade", - "defaultVisibility": "Visibilidade predefinida", - "conference": "Conferência", - "noConference": "Sem conferência", - "providerCalendar": "Calendário do fornecedor", - "formatBoldShortLabel": "N", - "formatBoldTooltip": "Negrito", - "formatItalicShortLabel": "I", - "formatItalicTooltip": "Itálico", - "formatUnderlineShortLabel": "S", - "formatUnderlineTooltip": "Sublinhado", - "reminderMinutesBefore": "{minutes, plural, =1{1 minuto antes} other{{minutes} minutos antes}}", - "reminderAtStart": "À hora de início", - "reminderHoursBefore": "{hours, plural, =1{1 hora antes} other{{hours} horas antes}}", - "reminderDaysBefore": "{days, plural, =1{1 dia antes} other{{days} dias antes}}", - "availabilityFree": "Livre", - "availabilityTentative": "Provisório", - "availabilityOutOfOffice": "Fora do escritório", - "availabilityWorkingElsewhere": "A trabalhar noutro local", - "visibilityDefault": "Predefinida", - "visibilityPublic": "Pública", - "visibilityPrivate": "Privada", - "visibilityConfidential": "Confidencial", - "sensitivityNormal": "Normal", - "sensitivityPersonal": "Pessoal", - "tasks": "Tarefas", - "allTasks": "Todas as tarefas", - "tasksInList": "Tarefas em {title}", - "taskLists": "Listas de tarefas", - "navigation": "Navegação", - "mainMenu": "Menu principal", - "keyboardShortcuts": "Atalhos de teclado", - "shortcutGroupGeneral": "Geral", - "shortcutKeyboardShortcutsDescription": "Mostrar esta referência de atalhos", - "shortcutGroupNavigation": "Navegação", - "shortcutNextPeriod": "Período seguinte", - "shortcutNextPeriodDescription": "Semana seguinte na vista semanal, mês seguinte na vista mensal e assim por diante", - "shortcutPreviousPeriod": "Período anterior", - "shortcutPreviousPeriodDescription": "Semana anterior na vista semanal, mês anterior na vista mensal e assim por diante", - "shortcutJumpToToday": "Ir para hoje", - "shortcutGroupView": "Vista", - "shortcutDayView": "Vista diária", - "shortcutWeekView": "Vista semanal", - "shortcutMonthView": "Vista mensal", - "shortcutYearView": "Vista anual", - "shortcutAgendaView": "Vista de agenda", - "shortcutGroupCreateAndEdit": "Criar e editar", - "shortcutSaveItem": "Guardar evento ou tarefa", - "shortcutDeleteItem": "Eliminar evento ou tarefa", - "shortcutGroupTaskEditing": "Edição de tarefas", - "shortcutCancelEditing": "Cancelar edição", - "shortcutCancelEditingDescription": "Fechar a edição ou os detalhes da tarefa", - "aboutBusyMax": "Acerca do BusyMax", - "aboutBusyMaxDescription": "Calendário e tarefas", - "license": "Licença", - "apacheLicenseName": "Apache License 2.0", - "website": "Site", - "sourceCode": "Código-fonte", - "reportAnIssue": "Comunicar um problema", - "sendFeedback": "Enviar comentários", - "feedbackSubmit": "Enviar", - "feedbackCategory": "Categoria", - "feedbackSelectCategory": "Selecione uma categoria", - "feedbackCategoryProblem": "Problema ou erro", - "feedbackCategoryFeature": "Pedido de funcionalidade", - "feedbackCategoryPrivacySecurity": "Questão de privacidade ou segurança", - "feedbackCategoryUsability": "Problema de utilização", - "feedbackCategoryOther": "Outro", - "feedbackSubject": "Assunto", - "feedbackDetailedMessage": "Mensagem detalhada", - "feedbackReplyEmail": "Endereço de e-mail para resposta (opcional)", - "feedbackIncludeTechnicalDetails": "Incluir detalhes técnicos", - "feedbackTechnicalDetailsDisclosure": "Adiciona apenas a versão do sistema operativo Linux e a configuração regional da aplicação. Não são incluídos registos, dados de contas, nomes de ficheiros nem outros diagnósticos.", - "feedbackCategoryRequired": "Selecione uma categoria.", - "feedbackSubjectLengthError": "O assunto deve ter entre 3 e 120 carateres.", - "feedbackMessageLengthError": "A mensagem deve ter entre 10 e 5 000 carateres.", - "feedbackInvalidEmail": "Introduza um endereço de e-mail válido.", - "feedbackConnectionError": "Não foi possível ligar ao BusyStack. Verifique a ligação e tente novamente.", - "feedbackTimeoutError": "O pedido excedeu o tempo limite. Os seus comentários não foram apagados; tente novamente.", - "feedbackRateLimitedError": "Foram enviados demasiados comentários a partir desta rede. Aguarde e tente novamente.", - "feedbackRejectedError": "O servidor rejeitou o envio. Reveja os campos e tente novamente.", - "feedbackServerError": "O BusyStack não pode aceitar os seus comentários neste momento. Os seus comentários não foram apagados; tente novamente.", - "feedbackSuccess": "Comentários enviados. Referência: {id}", - "toggleSidebar": "Mostrar ou ocultar a barra lateral", - "showSidebar": "Mostrar painel lateral", - "hideSidebar": "Ocultar painel lateral", - "accounts": "Contas", - "currentAccount": "Conta atual", - "switchAccount": "Mudar de conta", - "addGoogleAccount": "Adicionar conta Google", - "addMicrosoftAccount": "Adicionar conta Microsoft", - "googleProvider": "Google", - "microsoftProvider": "Microsoft", - "signedInAccount": "Sessão iniciada", - "removeAccount": "Remover conta…", - "removingAccount": "A remover conta…", - "removeAccountDescription": "Parar a sincronização e remover os dados desta conta deste dispositivo.", - "removeAccountTitle": "Remover {account} do BusyMax?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", - "revokeGoogleAccess": "Revogar também o acesso do BusyMax a esta conta Google", - "revokeGoogleAccessDescription": "Terá de conceder acesso novamente antes de voltar a ligar a conta.", - "removeAccountAction": "Remover conta", - "removeAccountFailed": "Não foi possível concluir a remoção da conta. Tente novamente.", - "accountRemovedGoogleRevokeFailed": "A conta foi removida deste dispositivo, mas não foi possível revogar o acesso do BusyMax à sua conta Google. Pode revogar esse acesso na sua conta Google.", - "newTaskList": "Nova lista de tarefas", - "signInToViewTaskLists": "Inicie sessão para ver as listas de tarefas.", - "noTaskListsSynced": "Ainda não há listas de tarefas sincronizadas.", - "listActions": "Ações da lista", - "rename": "Mudar o nome", - "delete": "Eliminar", - "renameList": "Mudar o nome da lista", - "deleteList": "Eliminar lista", - "builtInMicrosoftList": "Incorporada", - "builtInMicrosoftListCannotRenameDelete": "As listas incorporadas do Microsoft To Do não podem ser renomeadas nem eliminadas.", - "deleteListConfirmation": "Eliminar «{title}» do Google Tasks?", - "deleteEvent": "Eliminar evento", - "title": "Título", - "create": "Criar", - "newTask": "Nova tarefa", - "clearCompleted": "Limpar tarefas concluídas", - "refreshList": "Atualizar lista", - "refreshAll": "Atualizar tudo", - "listRefreshed": "Lista atualizada.", - "allTasksRefreshed": "Todas as contas foram atualizadas.", - "exportedFile": "Exportado para {path}", - "exportFailed": "Falha ao exportar: {error}", - "refreshFailed": "Falha ao atualizar: {error}", - "selectOrCreateTaskList": "Selecione ou crie uma lista de tarefas para começar.", - "signInToViewTasks": "Inicie sessão para ver as tarefas.", - "noTasks": "Sem tarefas.", - "noTasksYet": "Ainda não há tarefas", - "noTasksYetMessage": "Crie uma tarefa ou atualize as suas contas para começar.", - "noTasksInList": "Não há tarefas nesta lista.", - "overdue": "Em atraso", - "today": "Hoje", - "tomorrow": "Amanhã", - "upcoming": "Próximas", - "noDate": "Sem data", - "completed": "Concluídas", - "duePrefix": "Prazo: {date}", - "dateTimeDisplay": "{date}, {time}", - "taskDetails": "Detalhes da tarefa", - "editTask": "Editar tarefa", - "noTaskSelected": "Nenhuma tarefa selecionada.", - "noTaskSelectedHelper": "Selecione uma tarefa para ver e editar os detalhes.", - "taskUnavailable": "Tarefa indisponível.", - "signInToEditTasks": "Inicie sessão para editar tarefas.", - "refreshTask": "Atualizar tarefa", - "primarySection": "Principal", - "statusSection": "Estado", - "openStatus": "Aberta", - "doneStatus": "Concluída", - "notes": "Notas", - "dueDate": "Data limite", - "clearDueDate": "Limpar data limite", - "dueTime": "Hora limite", - "startDate": "Data de início", - "startTime": "Hora de início", - "endDate": "Data de fim", - "endTime": "Hora de fim", - "reminderDate": "Data do lembrete", - "reminderTime": "Hora do lembrete", - "reminder": "Lembrete", - "addReminder": "Adicionar lembrete", - "addGuest": "Adicionar convidado", - "addGuestEmail": "Adicionar e-mail do convidado", - "removeReminder": "Remover lembrete", - "off": "Desativado", - "repeat": "Repetição", - "repeatNone": "Não repetir", - "noneValue": "Nenhum", - "repeatDaily": "Diariamente", - "repeatWeekly": "Semanalmente", - "repeatMonthly": "Mensalmente", - "repeatYearly": "Anualmente", - "importance": "Importância", - "importanceLow": "Baixa", - "importanceNormal": "Normal", - "importanceHigh": "Alta", - "categories": "Categorias", - "scheduleSection": "Agenda", - "dueGroup": "Prazo", - "startGroup": "Início", - "reminderGroup": "Lembrete", - "organizationSection": "Organização", - "actionsSection": "Ações", - "advancedSection": "Avançado", - "addCategory": "Adicionar categoria", - "list": "Lista", - "microsoftMoveUnsupported": "Nesta versão, não é possível mover tarefas entre listas em contas Microsoft To Do.", - "createSubtask": "Criar subtarefa", - "subtasks": "Subtarefas", - "moveToTop": "Mover para o início", - "deleteTask": "Eliminar tarefa", - "newSubtask": "Nova subtarefa", - "deleteTaskConfirmation": "Eliminar «{title}»?", - "metadata": "Metadados", - "id": "ID", - "etag": "ETag", - "updated": "Atualizado", - "parent": "Tarefa principal", - "position": "Posição", - "webLink": "Ligação Web", - "assignment": "Atribuição", - "localState": "Estado local", - "pendingSync": "Sincronização pendente", - "synced": "Sincronizado", - "account": "Conta", - "sync": "Sincronização", - "manualFullSync": "Sincronização completa manual", - "runInBackgroundWhenClosed": "Continuar em execução quando a janela for fechada", - "showTrayIcon": "Mostrar ícone na área de notificação", - "startMinimizedToTray": "Iniciar minimizado na área de notificação", - "launchAtLogin": "Iniciar ao entrar", - "launchAtLoginDescription": "Inicie o BusyMax em segundo plano para que os lembretes funcionem após entrar.", - "launchAtLoginFailed": "Não foi possível atualizar o início de sessão.", - "requiresTrayIcon": "Requer o ícone da área de notificação.", - "syncComplete": "Sincronização concluída.", - "syncFailed": "Falha na sincronização: {error}", - "notifySyncFailures": "Notificações de falhas de sincronização", - "notifyConflicts": "Notificações de conflitos", - "notifyDueToday": "Notificações de tarefas com prazo para hoje", - "eventReminders": "Lembretes de eventos", - "taskReminders": "Lembretes de tarefas", - "notificationDetailLevel": "Nível de detalhe das notificações", - "notificationDetailPrivate": "Privado", - "notificationDetailNormal": "Normal", - "quietHours": "Período de silêncio", - "quietHoursDescription": "Pausar as notificações durante este período.", - "quietHoursStart": "Início do período de silêncio", - "quietHoursEnd": "Fim do período de silêncio", - "notifications": "Notificações", - "appearance": "Aspeto", - "theme": "Tema", - "themeSystem": "Sistema", - "themeLight": "Claro", - "themeDark": "Escuro", - "themeFamily": "Família de temas", - "themeFamilyYaru": "Tema nativo do Ubuntu (Yaru)", - "localization": "Localização", - "currentLocale": "Configuração regional atual", - "privacy": "Privacidade", - "redactTaskContentInDiagnostics": "Ocultar o conteúdo das tarefas nos diagnósticos", - "developerDiagnostics": "Diagnósticos de programador", - "diagnostics": "Diagnósticos", - "apiInspectorDisabled": "Mostrar inspetor da API", - "googleTasksApi": "API do Google Tasks", - "discoveryRevision": "Revisão de descoberta: {revision}", - "implementedMethods": "Métodos implementados", - "supportsTasksScopes": "Suporta os âmbitos de autorização tasks e tasks.readonly", - "requiresTasksScope": "Requer o âmbito de autorização tasks", - "blockedPendingOperations": "Operações pendentes bloqueadas", - "signInToInspectPendingOperations": "Inicie sessão para inspecionar as operações pendentes.", - "noBlockedPendingOperations": "Não há operações pendentes bloqueadas.", - "operationActions": "Ações da operação", - "pendingOpListId": "lista={id}", - "pendingOpTaskId": "tarefa={id}", - "pendingOpAttempts": "tentativas={count}", - "retry": "Tentar novamente", - "discard": "Descartar", - "discardChangesAction": "Descartar", - "discardChanges": "Descartar alterações?", - "discardChangesConfirmation": "Esta ação descarta as alterações não guardadas nesta tarefa.", - "retryCompleted": "Nova tentativa concluída.", - "discardPendingOperation": "Descartar operação pendente?", - "discardPendingOperationConfirmation": "Esta ação remove a operação local bloqueada. Na próxima sincronização, os dados serão novamente carregados do Google Tasks.", - "pendingOperationDiscarded": "Operação pendente descartada.", - "syncFailureNotificationTitle": "Falha na sincronização do BusyMax", - "syncFailureNotificationBody": "A sincronização em segundo plano falhou. {message}", - "conflictNotificationTitle": "Conflito de sincronização do BusyMax", - "conflictNotificationBody": "Uma alteração local pendente foi bloqueada. {summary}", - "dueTodayNotificationTitle": "Tarefas com prazo para hoje", - "dueTodayNotificationBody": "{count, plural, =1{Há uma tarefa com prazo para hoje.} other{Há {count} tarefas com prazo para hoje.}}", - "eventReminderNotificationTitle": "Lembrete de evento", - "taskReminderNotificationTitle": "Lembrete de tarefa", - "eventReminderNotificationBody": "O evento começa em breve.", - "taskReminderNotificationBody": "O prazo da tarefa aproxima-se.", - "notificationOpenAction": "Abrir", - "notificationSnoozeAction": "Adiar 10 minutos", - "notificationDismissAction": "Dispensar", - "notificationDetailsHidden": "Os detalhes estão ocultos pelas definições de privacidade.", - "previousMonth": "Mês anterior", - "nextMonth": "Mês seguinte", - "openMonthView": "Abrir vista mensal", - "previousYear": "Ano anterior", - "nextYear": "Ano seguinte", - "openYearView": "Abrir vista anual", - "weekNumberTooltip": "Semana {number}", - "resizeAllDayPanel": "Redimensionar o painel de dia inteiro", - "scheduleItemCount": "{count, plural, =1{1 item} other{{count} itens}}", - "readOnlyCalendar": "Este calendário é só de leitura.", - "selectTimeZone": "Selecionar fuso horário", - "searchLocations": "Pesquisar locais", - "noLocationsFound": "Nenhum local encontrado", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", - "nextcloudProvider": "Nextcloud", - "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", - "deleteCalendarConfirmation": "Eliminar «{title}»?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", - "@davLastSuccessfulSync": { - "placeholders": { - "time": { - "type": "String" - } - } - }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", - "@nextcloudServerHost": { - "placeholders": { - "host": { - "type": "String" - } - } - }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", - "@collectionLastSynced": { - "placeholders": { - "time": { - "type": "String" - } - } - }, - "collectionSyncError": "Sync issue: {code}", - "@collectionSyncError": { - "placeholders": { - "code": { - "type": "String" - } - } - }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", - "@remoteChangedAt": { - "placeholders": { - "time": { - "type": "String" - } - } - }, - "localPendingEdit": "Local edit: {summary}", - "@localPendingEdit": { - "placeholders": { - "summary": { - "type": "String" - } - } - }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", - "taskDueBeforeStart": "O prazo não pode ser anterior ao início.", - "taskStartDueTimeModeMismatch": "Defina horários para o início e o prazo, ou torne a tarefa de dia inteiro.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", - "unshare": "Cancelar partilha", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", - "taskStatusNeedsAction": "Needs action", - "taskStatusInProcess": "Em andamento", - "taskStatusCompleted": "Concluída", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", - "priority": "Prioridade", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "No reminders", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Before the task starts", - "beforeTaskDue": "Before the task is due", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "Repetir a cada", - "repeatOn": "Repeat on", - "repeatEnd": "End repeat", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "em {days}", - "repeatOnMonthDaysSummary": "on day {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", - "repeatTimesSummary": "{count} vezes", - "repeatUntilSummary": "até {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", - "hideSubtasks": "Ocultar subtarefas", - "hideClosedSubtasks": "Hide closed subtasks", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", - "networkOffline": "Offline", - "networkOfflineDescription": "As alterações serão sincronizadas quando a ligação for restabelecida.", - "networkOfflineTryAgain": "Está offline. Ligue-se à Internet e tente novamente." + "@@locale": "pt" } diff --git a/lib/l10n/app_pt_PT.arb b/lib/l10n/app_pt_PT.arb new file mode 100644 index 00000000..e89498ad --- /dev/null +++ b/lib/l10n/app_pt_PT.arb @@ -0,0 +1,949 @@ +{ + "@@locale": "pt_PT", + "windowsSupport": "Suporte", + "windowsThirdPartyLicenses": "Licenças de terceiros", + "windowsSearch": "Pesquisar", + "windowsStartupDisabledByUser": "Desativado pelo utilizador nas Definições do Windows.", + "windowsStartupDisabledByPolicy": "Desativado por uma política do Windows.", + "windowsStartupUnavailable": "Disponível após a instalação do BusyMax a partir de um pacote MSIX.", + "windowsReminderExitNotice": "Os lembretes param quando o BusyMax é totalmente encerrado. Mantenha-o em execução em segundo plano para os receber.", + "windowsProductVersionLabel": "Versão do produto", + "windowsPackageVersionLabel": "Versão do pacote do Windows", + "windowsUnpackaged": "Sem pacote", + "windowsAgendaLoadMore": "Carregar mais itens da agenda", + "repeatWeeklyDaySummary": "{dayKey, select, MO{à segunda-feira} TU{à terça-feira} WE{à quarta-feira} TH{à quinta-feira} FR{à sexta-feira} SA{ao sábado} SU{ao domingo} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "nos dias {first} e {second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} nos dias {firstDay} e {secondDay} de {month}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} no dia {day} de {firstMonth} e {secondMonth}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} nos dias {firstDay} e {secondDay} de {firstMonth} e {secondMonth}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} nos dias {days} de {firstMonth} e {secondMonth}", + "appTitle": "BusyMax", + "connectGoogleAccount": "Ligue as contas Google, Microsoft, Calendário Apple iCloud ou Nextcloud.", + "googlePermissionsConsentNotice": "No ecrã de autorizações da Google, selecione as autorizações do Calendário e das Tarefas.", + "googlePermissionsRequiredRetry": "As autorizações do Calendário Google e do Google Tasks são necessárias. Tente novamente e selecione ambas as caixas.", + "finishSetup": "Concluir configuração", + "continueSetup": "Continuar", + "onboardingSetupTitle": "Configurar o BusyMax", + "onboardingAccountsStepTitle": "Ligar contas", + "onboardingAccountsStepDescription": "Adicione todas as contas que pretende utilizar. O BusyMax sincroniza calendários, eventos, listas de tarefas e tarefas suportados de cada conta.", + "onboardingPreferencesStepTitle": "Escolher definições do sistema", + "onboardingPreferencesStepDescription": "Configure o comportamento da aplicação no ambiente de trabalho, os lembretes, o nível de detalhe das notificações e o aspeto antes de abrir a agenda.", + "signInWithGoogle": "Iniciar sessão com a Google", + "signInWithMicrosoft": "Iniciar sessão com a Microsoft", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "Este fornecedor não está configurado.", + "waitingForGoogleSignIn": "A aguardar o início de sessão da Google...", + "waitingForMicrosoftSignIn": "A aguardar o início de sessão da Microsoft...", + "microsoftSignInNotConfigured": "O início de sessão da Microsoft não está configurado. Defina MICROSOFT_OAUTH_CLIENT_ID.", + "cancel": "Cancelar", + "close": "Fechar", + "exit": "Sair", + "options": "Opções", + "hide": "Ocultar", + "show": "Mostrar", + "export": "Exportar", + "save": "Guardar", + "settings": "Definições", + "all": "Tudo", + "calendarEvents": "Eventos", + "calendarTasks": "Tarefas", + "calendar": "Calendário", + "calendars": "Calendários", + "newCalendar": "Novo calendário", + "calendarColor": "Cor do calendário", + "calendarColorOption": "Cor {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "Este fornecedor não suporta a gestão de calendários no BusyMax.", + "primaryCalendarCannotDelete": "O calendário principal não pode ser eliminado.", + "calendarCreateFailed": "Não foi possível criar o calendário: {error}", + "calendarCreatedRefreshPending": "O calendário foi criado, mas o BusyMax não conseguiu atualizar a conta. Será apresentado após a próxima sincronização.", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "Não foi possível atualizar o calendário: {error}", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "Não foi possível eliminar o calendário: {error}", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "newEvent": "Novo evento", + "refreshCalendar": "Atualizar calendário", + "openInProvider": "Abrir no serviço", + "hideFromSchedule": "Ocultar da agenda", + "showInSchedule": "Mostrar na agenda", + "noCalendarsSynced": "Ainda não há calendários sincronizados.", + "allDay": "Todo o dia", + "moreItems": "+{count} mais", + "noEventsOrTasks": "Sem eventos ou tarefas", + "scheduleLoading": "A carregar agenda...", + "scheduleUnavailable": "Agenda indisponível", + "scheduleNoSources": "Sem calendários ou listas de tarefas visíveis", + "scheduleNoSourcesDescription": "Escolha o que pretende mostrar nas Definições e atualize a agenda.", + "scheduleSignInRequired": "Ligar uma conta", + "scheduleSignInDescription": "Inicie sessão para sincronizar calendários e tarefas.", + "scheduleNoSearchResults": "Nenhum evento ou tarefa correspondente", + "scheduleNoSearchResultsDescription": "Experimente outra pesquisa ou limpe os filtros atuais.", + "refresh": "Atualizar", + "trayOpenBusyMax": "Abrir o BusyMax", + "trayShowBusyMax": "Mostrar o BusyMax", + "trayNewEvent": "Novo evento…", + "trayNewTask": "Nova tarefa…", + "trayToday": "Hoje", + "trayAllDay": "Todo o dia", + "trayNow": "Agora", + "trayCalendarEvent": "Evento do calendário", + "trayUntitledEvent": "Evento sem título", + "trayNothingElseToday": "Nada mais hoje", + "trayTasksDueToday": "{count, plural, =1{1 tarefa termina hoje} other{{count} tarefas terminam hoje}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Abrir a agenda de hoje", + "traySyncNow": "Sincronizar agora", + "traySyncing": "A sincronizar…", + "trayNotConnected": "Não ligado", + "trayNotYetSynced": "Ainda não sincronizado", + "trayLastSyncedJustNow": "Sincronizado agora mesmo", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Sincronizado há 1 minuto} other{Sincronizado há {count} minutos}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Sincronizado há 1 hora} other{Sincronizado há {count} horas}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Sincronizado há 1 dia} other{Sincronizado há {count} dias}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Definições", + "trayQuitBusyMax": "Sair do BusyMax", + "agendaLoadMoreOverdue": "Carregar mais tarefas em atraso", + "agendaLoadMoreNoDate": "Carregar mais tarefas sem data", + "viewDay": "Dia", + "viewWeek": "Semana", + "viewMonth": "Mês", + "viewYear": "Ano", + "viewAgenda": "Vista de agenda", + "scheduleSettings": "Agenda", + "scheduleDisplaySettings": "Apresentação da agenda", + "scheduleDisplayHoursDescription": "As vistas de dia e semana mostram inicialmente este intervalo horário. Os itens anteriores ou posteriores alargam-no quando necessário.", + "scheduleDayStartsAt": "O dia começa às", + "scheduleDayEndsAt": "O dia termina às", + "sourceCalendar": "Calendário", + "sourceTaskList": "Lista de tarefas", + "createChoiceTitle": "Criar", + "createEventAtTime": "Evento", + "createTaskAtDate": "Tarefa", + "editEvent": "Editar evento", + "eventTitle": "Título do evento", + "location": "Local", + "timeSlot": "Intervalo de tempo", + "startDateTime": "Data e hora de início", + "endDateTime": "Data e hora de fim", + "doesNotRepeat": "Não se repete", + "defaultReminder": "Lembrete predefinido", + "guests": "Convidados", + "noGuests": "Sem convidados", + "description": "Descrição", + "availabilityShowAs": "Disponibilidade / Mostrar como", + "busy": "Ocupado", + "visibility": "Visibilidade", + "defaultVisibility": "Visibilidade predefinida", + "conference": "Conferência", + "noConference": "Sem conferência", + "providerCalendar": "Calendário do fornecedor", + "formatBoldShortLabel": "N", + "formatBoldTooltip": "Negrito", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "Itálico", + "formatUnderlineShortLabel": "S", + "formatUnderlineTooltip": "Sublinhado", + "reminderMinutesBefore": "{minutes, plural, =1{1 minuto antes} other{{minutes} minutos antes}}", + "reminderAtStart": "À hora de início", + "reminderHoursBefore": "{hours, plural, =1{1 hora antes} other{{hours} horas antes}}", + "reminderDaysBefore": "{days, plural, =1{1 dia antes} other{{days} dias antes}}", + "availabilityFree": "Livre", + "availabilityTentative": "Provisório", + "availabilityOutOfOffice": "Fora do escritório", + "availabilityWorkingElsewhere": "A trabalhar noutro local", + "visibilityDefault": "Predefinida", + "visibilityPublic": "Pública", + "visibilityPrivate": "Privada", + "visibilityConfidential": "Confidencial", + "sensitivityNormal": "Normal", + "sensitivityPersonal": "Pessoal", + "tasks": "Tarefas", + "allTasks": "Todas as tarefas", + "tasksInList": "Tarefas em {title}", + "taskLists": "Listas de tarefas", + "navigation": "Navegação", + "mainMenu": "Menu principal", + "keyboardShortcuts": "Atalhos de teclado", + "shortcutGroupGeneral": "Geral", + "shortcutKeyboardShortcutsDescription": "Mostrar esta referência de atalhos", + "shortcutGroupNavigation": "Navegação", + "shortcutNextPeriod": "Período seguinte", + "shortcutNextPeriodDescription": "Semana seguinte na vista semanal, mês seguinte na vista mensal e assim por diante", + "shortcutPreviousPeriod": "Período anterior", + "shortcutPreviousPeriodDescription": "Semana anterior na vista semanal, mês anterior na vista mensal e assim por diante", + "shortcutJumpToToday": "Ir para hoje", + "shortcutGroupView": "Vista", + "shortcutDayView": "Vista diária", + "shortcutWeekView": "Vista semanal", + "shortcutMonthView": "Vista mensal", + "shortcutYearView": "Vista anual", + "shortcutAgendaView": "Vista de agenda", + "shortcutGroupCreateAndEdit": "Criar e editar", + "shortcutSaveItem": "Guardar evento ou tarefa", + "shortcutDeleteItem": "Eliminar evento ou tarefa", + "shortcutGroupTaskEditing": "Edição de tarefas", + "shortcutCancelEditing": "Cancelar edição", + "shortcutCancelEditingDescription": "Fechar a edição ou os detalhes da tarefa", + "aboutBusyMax": "Acerca do BusyMax", + "aboutBusyMaxDescription": "Calendário e tarefas", + "license": "Licença", + "apacheLicenseName": "Apache License 2.0", + "website": "Site", + "sourceCode": "Código-fonte", + "reportAnIssue": "Comunicar um problema", + "sendFeedback": "Enviar comentários", + "feedbackSubmit": "Enviar", + "feedbackCategory": "Categoria", + "feedbackSelectCategory": "Selecione uma categoria", + "feedbackCategoryProblem": "Problema ou erro", + "feedbackCategoryFeature": "Pedido de funcionalidade", + "feedbackCategoryPrivacySecurity": "Questão de privacidade ou segurança", + "feedbackCategoryUsability": "Problema de utilização", + "feedbackCategoryOther": "Outro", + "feedbackSubject": "Assunto", + "feedbackDetailedMessage": "Mensagem detalhada", + "feedbackReplyEmail": "Endereço de e-mail para resposta (opcional)", + "feedbackIncludeTechnicalDetails": "Incluir detalhes técnicos", + "feedbackTechnicalDetailsDisclosure": "Adiciona apenas o nome e a versão do sistema operativo e a configuração regional da aplicação. Não são incluídos registos, dados de contas, nomes de ficheiros nem outros diagnósticos.", + "feedbackCategoryRequired": "Selecione uma categoria.", + "feedbackSubjectLengthError": "O assunto deve ter entre 3 e 120 carateres.", + "feedbackMessageLengthError": "A mensagem deve ter entre 10 e 5 000 carateres.", + "feedbackInvalidEmail": "Introduza um endereço de e-mail válido.", + "feedbackConnectionError": "Não foi possível ligar ao BusyStack. Verifique a ligação e tente novamente.", + "feedbackTimeoutError": "O pedido excedeu o tempo limite. Os seus comentários não foram apagados; tente novamente.", + "feedbackRateLimitedError": "Foram enviados demasiados comentários a partir desta rede. Aguarde e tente novamente.", + "feedbackRejectedError": "O servidor rejeitou o envio. Reveja os campos e tente novamente.", + "feedbackServerError": "O BusyStack não pode aceitar os seus comentários neste momento. Os seus comentários não foram apagados; tente novamente.", + "feedbackSuccess": "Comentários enviados. Referência: {id}", + "toggleSidebar": "Mostrar ou ocultar a barra lateral", + "showSidebar": "Mostrar painel lateral", + "hideSidebar": "Ocultar painel lateral", + "accounts": "Contas", + "currentAccount": "Conta atual", + "switchAccount": "Mudar de conta", + "addGoogleAccount": "Adicionar conta Google", + "addMicrosoftAccount": "Adicionar conta Microsoft", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "Sessão iniciada", + "removeAccount": "Remover conta…", + "removingAccount": "A remover conta…", + "removeAccountDescription": "Parar a sincronização e remover os dados desta conta deste dispositivo.", + "removeAccountTitle": "Remover {account} do BusyMax?", + "removeAccountConfirmation": "Esta ação elimina deste dispositivo as tarefas, calendários, eventos, lembretes e alterações offline pendentes colocados em cache. As alterações não sincronizadas serão perdidas. As cópias dos calendários, eventos, listas de tarefas e tarefas no fornecedor não são eliminadas.", + "revokeGoogleAccess": "Revogar também o acesso do BusyMax a esta conta Google", + "revokeGoogleAccessDescription": "Terá de conceder acesso novamente antes de voltar a ligar a conta.", + "removeAccountAction": "Remover conta", + "removeAccountFailed": "Não foi possível concluir a remoção da conta. Tente novamente.", + "accountRemovedGoogleRevokeFailed": "A conta foi removida deste dispositivo, mas não foi possível revogar o acesso do BusyMax à sua conta Google. Pode revogar esse acesso na sua conta Google.", + "newTaskList": "Nova lista de tarefas", + "signInToViewTaskLists": "Inicie sessão para ver as listas de tarefas.", + "noTaskListsSynced": "Ainda não há listas de tarefas sincronizadas.", + "listActions": "Ações da lista", + "rename": "Mudar o nome", + "delete": "Eliminar", + "renameList": "Mudar o nome da lista", + "deleteList": "Eliminar lista", + "builtInMicrosoftList": "Incorporada", + "builtInMicrosoftListCannotRenameDelete": "As listas incorporadas do Microsoft To Do não podem ser renomeadas nem eliminadas.", + "deleteListConfirmation": "Eliminar “{title}” do Google Tasks?", + "deleteEvent": "Eliminar evento", + "title": "Título", + "create": "Criar", + "newTask": "Nova tarefa", + "clearCompleted": "Limpar tarefas concluídas", + "refreshList": "Atualizar lista", + "refreshAll": "Atualizar tudo", + "listRefreshed": "Lista atualizada.", + "allTasksRefreshed": "Todas as contas foram atualizadas.", + "exportedFile": "Exportado para {path}", + "exportFailed": "Falha ao exportar: {error}", + "refreshFailed": "Falha ao atualizar: {error}", + "selectOrCreateTaskList": "Selecione ou crie uma lista de tarefas para começar.", + "signInToViewTasks": "Inicie sessão para ver as tarefas.", + "noTasks": "Sem tarefas.", + "noTasksYet": "Ainda não há tarefas", + "noTasksYetMessage": "Crie uma tarefa ou atualize as suas contas para começar.", + "noTasksInList": "Não há tarefas nesta lista.", + "overdue": "Em atraso", + "today": "Hoje", + "tomorrow": "Amanhã", + "upcoming": "Próximas", + "noDate": "Sem data", + "completed": "Concluídas", + "duePrefix": "Prazo: {date}", + "dateTimeDisplay": "{date}, {time}", + "taskDetails": "Detalhes da tarefa", + "editTask": "Editar tarefa", + "noTaskSelected": "Nenhuma tarefa selecionada.", + "noTaskSelectedHelper": "Selecione uma tarefa para ver e editar os detalhes.", + "taskUnavailable": "Tarefa indisponível.", + "signInToEditTasks": "Inicie sessão para editar tarefas.", + "refreshTask": "Atualizar tarefa", + "primarySection": "Principal", + "statusSection": "Estado", + "openStatus": "Aberta", + "doneStatus": "Concluída", + "notes": "Notas", + "dueDate": "Data limite", + "clearDueDate": "Limpar data limite", + "dueTime": "Hora limite", + "startDate": "Data de início", + "startTime": "Hora de início", + "endDate": "Data de fim", + "endTime": "Hora de fim", + "reminderDate": "Data do lembrete", + "reminderTime": "Hora do lembrete", + "reminder": "Lembrete", + "addReminder": "Adicionar lembrete", + "addGuest": "Adicionar convidado", + "addGuestEmail": "Adicionar e-mail do convidado", + "removeReminder": "Remover lembrete", + "off": "Desativado", + "repeat": "Repetição", + "repeatNone": "Não repetir", + "noneValue": "Nenhum", + "repeatDaily": "Diariamente", + "repeatWeekly": "Semanalmente", + "repeatMonthly": "Mensalmente", + "repeatYearly": "Anualmente", + "importance": "Importância", + "importanceLow": "Baixa", + "importanceNormal": "Média", + "importanceHigh": "Alta", + "categories": "Categorias", + "scheduleSection": "Agenda", + "dueGroup": "Prazo", + "startGroup": "Início", + "reminderGroup": "Lembrete", + "organizationSection": "Organização", + "actionsSection": "Ações", + "advancedSection": "Avançado", + "addCategory": "Adicionar categoria", + "list": "Lista", + "microsoftMoveUnsupported": "Nesta versão, não é possível mover tarefas entre listas em contas Microsoft To Do.", + "createSubtask": "Criar subtarefa", + "subtasks": "Subtarefas", + "moveToTop": "Mover para o início", + "deleteTask": "Eliminar tarefa", + "newSubtask": "Nova subtarefa", + "deleteTaskConfirmation": "Eliminar «{title}»?", + "metadata": "Metadados", + "id": "ID", + "etag": "ETag", + "updated": "Atualizado", + "parent": "Tarefa principal", + "position": "Posição", + "webLink": "Ligação Web", + "assignment": "Atribuição", + "localState": "Estado local", + "pendingSync": "Sincronização pendente", + "synced": "Sincronizado", + "account": "Conta", + "sync": "Sincronização", + "forceFullResync": "Forçar nova sincronização completa", + "forceFullResyncDescription": "Recarrega completamente os dados de todas as contas ligadas. Utilize esta opção apenas para resolver problemas de sincronização.", + "runInBackgroundWhenClosed": "Continuar em execução quando a janela for fechada", + "showTrayIcon": "Mostrar ícone na área de notificação", + "startMinimizedToTray": "Iniciar minimizado na área de notificação", + "launchAtLogin": "Iniciar ao entrar", + "launchAtLoginDescription": "Inicie o BusyMax em segundo plano para que os lembretes funcionem após entrar.", + "launchAtLoginFailed": "Não foi possível atualizar o início de sessão.", + "requiresTrayIcon": "Requer o ícone da área de notificação.", + "syncComplete": "Sincronização concluída.", + "syncFailed": "Falha na sincronização: {error}", + "notifySyncFailures": "Notificações de falhas de sincronização", + "notifyConflicts": "Notificações de conflitos", + "notifyDueToday": "Notificações de tarefas com prazo para hoje", + "eventReminders": "Lembretes de eventos", + "onState": "Ativado", + "taskReminders": "Lembretes de tarefas", + "notificationDetailLevel": "Nível de detalhe das notificações", + "notificationDetailPrivate": "Privado", + "notificationDetailNormal": "Predefinido", + "quietHours": "Período de silêncio", + "quietHoursDescription": "Pausar as notificações durante este período.", + "quietHoursStart": "Início do período de silêncio", + "quietHoursEnd": "Fim do período de silêncio", + "notifications": "Notificações", + "windowsNotificationsUnavailable": "As notificações do Windows não estão disponíveis", + "windowsNotificationsUnpackaged": "Esta execução de desenvolvimento sem pacote não pode usar as notificações do Windows. Instale o MSIX assinado para teste para verificar os lembretes.", + "windowsNotificationsInstalledFailure": "O BusyMax não conseguiu inicializar as notificações do Windows. Os lembretes não serão apresentados até este problema de instalação ser resolvido.", + "appearance": "Aspeto", + "theme": "Tema", + "themeSystem": "Sistema", + "settingsSystem": "Sistema", + "themeLight": "Claro", + "themeDark": "Escuro", + "themeFamily": "Família de temas", + "themeFamilyYaru": "Tema nativo do Ubuntu (Yaru)", + "localization": "Localização", + "currentLocale": "Configuração regional atual", + "privacy": "Privacidade", + "redactTaskContentInDiagnostics": "Ocultar o conteúdo das tarefas nos diagnósticos", + "developerDiagnostics": "Diagnósticos de programador", + "diagnostics": "Diagnósticos", + "apiInspectorDisabled": "Mostrar inspetor da API", + "googleTasksApi": "API do Google Tasks", + "discoveryRevision": "Revisão de descoberta: {revision}", + "implementedMethods": "Métodos implementados", + "supportsTasksScopes": "Suporta os âmbitos de autorização tasks e tasks.readonly", + "requiresTasksScope": "Requer o âmbito de autorização tasks", + "blockedPendingOperations": "Operações pendentes bloqueadas", + "signInToInspectPendingOperations": "Inicie sessão para inspecionar as operações pendentes.", + "noBlockedPendingOperations": "Não há operações pendentes bloqueadas.", + "operationActions": "Ações da operação", + "pendingOpListId": "lista={id}", + "pendingOpTaskId": "tarefa={id}", + "pendingOpAttempts": "tentativas={count}", + "retry": "Tentar novamente", + "discard": "Descartar", + "discardChangesAction": "Descartar", + "discardChanges": "Descartar alterações?", + "discardChangesConfirmation": "Esta ação descarta as alterações não guardadas nesta tarefa.", + "retryCompleted": "Nova tentativa concluída.", + "discardPendingOperation": "Descartar operação pendente?", + "discardPendingOperationConfirmation": "Esta ação remove a operação local bloqueada. Na próxima sincronização, os dados serão novamente carregados do Google Tasks.", + "pendingOperationDiscarded": "Operação pendente descartada.", + "syncFailureNotificationTitle": "Falha na sincronização do BusyMax", + "syncFailureNotificationBody": "A sincronização em segundo plano falhou. {message}", + "conflictNotificationTitle": "Conflito de sincronização do BusyMax", + "conflictNotificationBody": "Uma alteração local pendente foi bloqueada. {summary}", + "dueTodayNotificationTitle": "Tarefas com prazo para hoje", + "dueTodayNotificationBody": "{count, plural, =1{Há uma tarefa com prazo para hoje.} other{Há {count} tarefas com prazo para hoje.}}", + "eventReminderNotificationTitle": "Lembrete de evento", + "taskReminderNotificationTitle": "Lembrete de tarefa", + "eventReminderNotificationBody": "O evento começa em breve.", + "taskReminderNotificationBody": "O prazo da tarefa aproxima-se.", + "notificationOpenAction": "Abrir", + "notificationSnoozeAction": "Adiar 10 minutos", + "notificationDismissAction": "Fechar", + "notificationDetailsHidden": "Os detalhes estão ocultos pelas definições de privacidade.", + "previousMonth": "Mês anterior", + "nextMonth": "Mês seguinte", + "openMonthView": "Abrir vista mensal", + "previousYear": "Ano anterior", + "nextYear": "Ano seguinte", + "openYearView": "Abrir vista anual", + "weekNumberTooltip": "Semana {number}", + "resizeAllDayPanel": "Redimensionar o painel de dia inteiro", + "scheduleItemCount": "{count, plural, =1{1 item} other{{count} itens}}", + "readOnlyCalendar": "Este calendário é só de leitura.", + "selectTimeZone": "Selecionar fuso horário", + "searchLocations": "Pesquisar locais", + "noLocationsFound": "Não foram encontrados locais", + "requiredField": "Este campo é obrigatório.", + "providerConnectionDescription": "Ligue calendários e tarefas a um destes fornecedores.", + "appleICloudProvider": "Calendário Apple iCloud", + "nextcloudProvider": "Nextcloud", + "appleICloudTasksProvider": "Apple iCloud", + "nextcloudTasksProvider": "Tarefas do Nextcloud", + "addAppleICloudAccount": "Adicionar conta do Calendário Apple iCloud", + "addNextcloudAccount": "Adicionar conta Nextcloud", + "waitingForAppleICloud": "A ligar ao Apple iCloud…", + "waitingForNextcloud": "A aguardar autorização do Nextcloud…", + "connectAppleICloudTitle": "Ligar o Calendário Apple iCloud", + "appleAccountEmail": "E-mail da conta Apple", + "appleAppSpecificPassword": "Palavra-passe específica da aplicação", + "appleAppSpecificPasswordHelp": "Crie uma palavra-passe específica da aplicação depois de ativar a autenticação de dois fatores na sua conta Apple.", + "appleAppSpecificPasswordResetWarning": "A reposição da palavra-passe da conta Apple revoga as palavras-passe específicas de aplicações.", + "connectNextcloudTitle": "Ligar ao Nextcloud", + "nextcloudServerUrl": "Servidor Nextcloud ou endereço CalDAV", + "nextcloudServerUrlHelp": "Introduza o URL do servidor Nextcloud ou cole o endereço CalDAV principal copiado do Nextcloud.", + "nextcloudBrowserAuthorizationHelp": "O BusyMax abrirá o navegador. Aprove o acesso aí e volte ao BusyMax.", + "connectAccountAction": "Ligar", + "cancelAccountConnection": "Cancelar ligação", + "nextcloudAccountRemovedRevokeFailed": "A conta foi removida localmente, mas não foi possível revogar a palavra-passe da aplicação Nextcloud.", + "davCachedOfflineNotice": "Os dados de calendários e tarefas são colocados em cache localmente para utilização offline.", + "davReauthenticationRequired": "Volte a ligar esta conta para retomar a sincronização.", + "davTemporarilyUnavailable": "Esta conta está temporariamente indisponível.", + "davPermissionChanged": "As permissões do servidor foram alteradas. As edições pendentes estão em pausa.", + "davUnsupportedServer": "Este servidor ou perfil do fornecedor não é suportado.", + "collectionSettings": "Calendários e listas de tarefas", + "calendarContent": "Eventos do calendário", + "taskContent": "Tarefas", + "readOnlySharedCollection": "Só de leitura", + "pendingLocally": "Pendente localmente", + "conflictBlocked": "Bloqueado por conflito", + "authenticationBlocked": "Bloqueado até voltar a ligar", + "operationFailed": "Operação falhou", + "keepServerVersion": "Manter versão do servidor", + "reapplyLocalChange": "Rever e reaplicar alteração local", + "duplicateLocalItem": "Duplicar como novo item", + "deleteCalendarConfirmation": "Eliminar «{title}»?", + "davConnectionState": "Estado da ligação", + "davConnected": "Ligado", + "davConnecting": "A ligar…", + "davSignedOut": "Sessão terminada", + "davLastSuccessfulSync": "Última sincronização bem-sucedida: {time}", + "@davLastSuccessfulSync": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "davNeverSynced": "Ainda não sincronizado", + "refreshCollections": "Atualizar calendários e listas de tarefas", + "nextcloudServerHost": "Servidor: {host}", + "@nextcloudServerHost": { + "placeholders": { + "host": { + "type": "String" + } + } + }, + "collectionSupportsEvents": "Calendário de eventos", + "collectionSupportsTasks": "Lista de tarefas", + "collectionSupportsEventsAndTasks": "Eventos e tarefas", + "writableCollection": "Editável", + "sharedCollection": "Partilhado", + "collectionLastSynced": "Última sincronização: {time}", + "@collectionLastSynced": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "collectionSyncError": "Problema de sincronização: {code}", + "@collectionSyncError": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "syncConflicts": "Conflitos de sincronização", + "remoteChangedAt": "Alteração no servidor: {time}", + "@remoteChangedAt": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "localPendingEdit": "Edição local: {summary}", + "@localPendingEdit": { + "placeholders": { + "summary": { + "type": "String" + } + } + }, + "conflictResolutionFailed": "Não foi possível resolver o conflito.", + "recurringEventScope": "Âmbito do evento recorrente", + "entireSeries": "Série inteira", + "singleOccurrence": "Este evento", + "thisAndFollowingEvents": "Este evento e os seguintes", + "thisAndFutureUnavailable": "Não suportado por este fornecedor.", + "thisAndFutureMoveUnavailable": "Este evento e os seguintes não podem ser movidos com segurança. Escolha este evento ou a série inteira.", + "entireSeriesMoveUnavailable": "A regra de recorrência não está disponível localmente. Mova apenas este evento.", + "copyEventAndDeleteOriginal": "Copiar o evento e eliminar o original?", + "copyEventMoveWarning": "O BusyMax não pode mover este evento diretamente de {source} para {destination}. Criará primeiro a cópia e eliminará o original apenas depois de a cópia ser criada com êxito. Os IDs do evento mudarão; os estados das respostas dos participantes poderão ser repostos e poderão ser enviados convites ou cancelamentos; as ligações de reuniões, os anexos, os lembretes, os campos específicos do fornecedor e as exceções de recorrência poderão não ser transferidos.", + "copyAndDelete": "Copiar e eliminar", + "chooseRecurringEventScope": "Escolha se esta alteração se aplica à série inteira, apenas a este evento ou a este e aos eventos seguintes.", + "taskDueBeforeStart": "O prazo não pode ser anterior ao início.", + "taskStartDueTimeModeMismatch": "Defina horários para o início e o prazo, ou torne a tarefa de dia inteiro.", + "taskListCreateFailed": "Não foi possível criar a lista de tarefas: {error}", + "taskListRenameFailed": "Não foi possível mudar o nome da lista de tarefas: {error}", + "taskListDeleteFailed": "Não foi possível eliminar a lista de tarefas: {error}", + "unshare": "Cancelar partilha", + "readOnlyTaskListCannotRename": "Esta lista de tarefas é só de leitura e não pode ter o nome alterado.", + "taskListCannotDelete": "Esta lista de tarefas não pode ser eliminada com as permissões atuais.", + "deleteTaskListConfirmation": "Eliminar “{title}” e todas as suas tarefas?", + "unshareTaskListConfirmation": "Cancelar a partilha de “{title}” desta conta?", + "taskStatus": "Estado", + "taskStatusNone": "Sem estado", + "taskStatusNeedsAction": "Requer ação", + "taskStatusInProcess": "Em curso", + "taskStatusCompleted": "Concluída", + "taskStatusCancelled": "Cancelada", + "completionPercent": "{percent}% concluída", + "completionDate": "Data de conclusão", + "priority": "Prioridade", + "priorityNone": "Sem prioridade", + "priorityHighValue": "Prioridade {priority} · Alta", + "priorityMediumValue": "Prioridade {priority} · Média", + "priorityLowValue": "Prioridade {priority} · Baixa", + "taskUrl": "URL da tarefa", + "invalidTaskUrl": "Introduza um URL absoluto, incluindo o esquema.", + "classification": "Classificação", + "classificationPublic": "Quando partilhada, mostrar a tarefa completa", + "classificationConfidential": "Quando partilhada, mostrar apenas ocupado", + "classificationPrivate": "Quando partilhada, ocultar esta tarefa", + "pinTask": "Afixar tarefa", + "reminders": "Lembretes", + "noReminders": "Sem lembretes", + "editReminder": "Editar lembrete", + "beforeTaskStarts": "Antes do início da tarefa", + "beforeTaskDue": "Antes do prazo da tarefa", + "afterTaskStarts": "Depois do início da tarefa", + "afterTaskDue": "Depois do prazo da tarefa", + "relativeToTaskStart": "Relativo à data de início da tarefa", + "relativeToTaskDue": "Relativo à data limite da tarefa", + "reminderTimeOfDay": "Hora do dia", + "absoluteReminder": "Numa data e hora", + "reminderAmount": "Quantidade", + "reminderUnitSeconds": "Segundos", + "reminderUnitMinutes": "Minutos", + "reminderUnitHours": "Horas", + "reminderUnitDays": "Dias", + "reminderUnitWeeks": "Semanas", + "reminderAtTaskStart": "No início da tarefa", + "reminderAtTaskDue": "No prazo da tarefa", + "unsupportedReminder": "Este tipo de lembrete é preservado, mas a hora não pode ser editada.", + "relatedRemindersTitle": "Manter lembretes relacionados?", + "relatedRemindersDescription": "Esta data tem {count} lembretes relacionados. Mantê-los na data e hora atuais?", + "discardRelatedReminders": "Eliminar lembretes", + "keepRelatedReminders": "Manter lembretes", + "repeatEvery": "Intervalo", + "repeatOn": "Repetir em", + "repeatEnd": "Terminar repetição", + "repeatNever": "Nunca", + "repeatUntil": "Numa data", + "repeatAfter": "Depois de um número de ocorrências", + "repeatCount": "Ocorrências", + "repeatDayOfMonth": "Dias do mês", + "repeatMonths": "Meses", + "repeatOrdinal": "Posição do dia da semana", + "repeatSpecificDays": "Dias específicos", + "repeatFirst": "Primeiro", + "repeatSecond": "Segundo", + "repeatThird": "Terceiro", + "repeatFourth": "Quarto", + "repeatFifth": "Quinto", + "repeatSecondToLast": "Penúltimo", + "repeatLast": "Último", + "repeatAnyDay": "Dia", + "repeatWeekday": "Dia útil", + "repeatWeekendDay": "Dia do fim de semana", + "repeatOrdinalDaySummary": "{dayKey, select, MO{segunda-feira} TU{terça-feira} WE{quarta-feira} TH{quinta-feira} FR{sexta-feira} SA{sábado} SU{domingo} day{dia} weekday{dia útil} weekend{dia do fim de semana} other{{day}}}", + "repeatEveryDays": "A cada {count} dias", + "repeatEveryWeeks": "A cada {count} semanas", + "repeatEveryMonths": "A cada {count} meses", + "repeatEveryYears": "A cada {count} anos", + "repeatOnDaysSummary": "{days}", + "repeatOnMonthDaysSummary": "no dia {days}", + "repeatOnOrdinalSummary": "{position, select, first{na primeira ocorrência de {days}} second{na segunda ocorrência de {days}} third{na terceira ocorrência de {days}} fourth{na quarta ocorrência de {days}} fifth{na quinta ocorrência de {days}} secondToLast{na penúltima ocorrência de {days}} last{na última ocorrência de {days}} other{em {days}}}", + "repeatInMonthsSummary": "em {months}", + "repeatTimesSummary": "{count, plural, one{{count} vez} other{{count} vezes}}", + "repeatUntilSummary": "até {date}", + "unsupportedRecurrencePreserved": "Esta regra de recorrência utiliza opções que este editor não altera.", + "recurrenceUnsupportedByProvider": "Esta recorrência não pode ser usada com {provider}.", + "duplicateTask": "Duplicar tarefa", + "taskDuplicated": "Tarefa duplicada.", + "taskDuplicateFailed": "Não foi possível duplicar a tarefa: {error}", + "hideSubtasks": "Ocultar subtarefas", + "hideClosedSubtasks": "Ocultar subtarefas concluídas", + "reminderUnit": "Unidade", + "attendeeRequired": "Obrigatório", + "attendeeOptional": "Opcional", + "meetingSection": "Reunião", + "addGoogleMeet": "Adicionar o Google Meet", + "addTeamsMeeting": "Adicionar reunião do Microsoft Teams", + "onlineMeetingAdded": "Reunião online adicionada", + "requestResponses": "Pedir respostas", + "requestResponsesDescription": "Peça aos convidados que respondam ao convite.", + "hideGuestList": "Ocultar lista de convidados", + "hideGuestListDescription": "Os convidados não podem ver quem mais foi convidado.", + "allowNewTimeProposals": "Permitir novas propostas de horário", + "allowNewTimeProposalsDescription": "Os convidados podem sugerir uma hora diferente para a reunião.", + "notifyGuestsTitle": "Notificar convidados?", + "notifyGuestsSaveMessage": "Esta reunião tem convidados. Enviar convites ou atualizações do evento ao guardá-la?", + "notifyGuestsDeleteMessage": "Esta reunião tem convidados. Enviar um cancelamento ao eliminá-la?", + "sendUpdates": "Enviar atualizações", + "sendCancellation": "Enviar cancelamento", + "doNotSend": "Não enviar", + "microsoftNotifyGuestsSaveTitle": "Guardar reunião?", + "microsoftNotifyGuestsSaveMessage": "A Microsoft enviará convites ou atualizações do evento aos convidados.", + "microsoftNotifyGuestsDeleteTitle": "Eliminar reunião?", + "microsoftNotifyGuestsDeleteMessage": "A Microsoft enviará um cancelamento aos convidados.", + "organizer": "Organizador", + "yourResponse": "A sua resposta", + "guestResponses": "Respostas dos convidados", + "respond": "Responder", + "acceptInvitation": "Aceitar", + "tentativeInvitation": "Provisório", + "declineInvitation": "Recusar", + "joinMeeting": "Participar na reunião", + "responseAccepted": "Aceite", + "responseTentative": "Provisório", + "responseDeclined": "Recusado", + "responseNeedsAction": "A aguardar resposta", + "responseNotResponded": "Sem resposta", + "responseOrganizer": "Organizador", + "invitationResponseFailed": "Não foi possível enviar a sua resposta: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "Não foi possível abrir a ligação da reunião.", + "setCustomCalendarName": "Definir nome personalizado", + "setAction": "Definir", + "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.", + "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.", + "calendarSubscriptions": "Subscrições de calendários", + "calendarSubscriptionsDescription": "Adicione calendários só de leitura que sejam atualizados a partir de um URL WebCal seguro.", + "addCalendarSubscription": "Adicionar subscrição de calendário", + "subscriptionName": "Nome local", + "subscriptionUrl": "URL da subscrição", + "subscriptionUrlHelp": "Introduza um URL HTTPS ou webcal. O BusyMax mantém o URL completo num armazenamento seguro.", + "subscriptionUrlInvalid": "Introduza um URL HTTPS ou webcal válido sem informações de utilizador ou fragmento.", + "subscriptionColor": "Cor local", + "subscriptionColorHelp": "Utilize uma cor de seis dígitos, como #3584E4.", + "subscriptionColorInvalid": "Introduza uma cor hexadecimal de seis dígitos.", + "subscriptionRefreshMode": "Frequência de atualização", + "subscriptionAutomatic": "Automática", + "subscriptionHourly": "De hora a hora", + "subscriptionSixHours": "A cada seis horas", + "subscriptionDaily": "Diária", + "subscriptionSafeOrigin": "Origem: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Introduza um URL válido para pré-visualizar a origem segura.", + "subscriptionReadOnly": "Subscrição só de leitura", + "subscriptionNeverRefreshed": "Ainda não atualizada", + "subscriptionLastRefresh": "Última atualização bem-sucedida: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Próxima atualização: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Atualizada", + "subscriptionStatusIssue": "Problema de atualização: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Atualizar agora", + "unsubscribe": "Cancelar subscrição", + "unsubscribeCalendarTitle": "Cancelar subscrição de “{name}”?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "Isto remove a subscrição local e os eventos colocados em cache. O calendário publicado não é alterado.", + "addSubscriptionAction": "Adicionar subscrição", + "subscriptionOperationFailed": "Falha na subscrição do calendário: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Subscrições", + "calendarImport": "Importação de calendário", + "calendarImportDescription": "Selecione um ficheiro, reveja os eventos e escolha o calendário editável que os deve receber.", + "importIcsFile": "Importar ficheiro .ics", + "importIcsPreview": "Importar eventos do calendário", + "importEventsFound": "Conjuntos de eventos importáveis: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Eventos inválidos: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Omitidos intencionalmente: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "Não está disponível nenhum calendário de destino editável.", + "importDestinationCalendar": "Calendário de destino", + "importIcsConfirm": "Importar eventos", + "importIcsComplete": "Importação concluída", + "importQueued": "Importados ou em fila: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Duplicados ignorados: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Conjuntos de recorrência não suportados: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "Não foi possível importar o ficheiro de calendário: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "networkOffline": "Sem ligação", + "networkOfflineDescription": "As alterações serão sincronizadas quando a ligação for restabelecida.", + "networkOfflineTryAgain": "Está offline. Ligue-se à Internet e tente novamente.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "nos dias {days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first} e {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} e {second}", + "repeatMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} e {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} e {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} no dia {day} de {month}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} no dia {day} de {months}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} nos dias {days} de {months}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{na primeira ocorrência de {days} de {months}} second{na segunda ocorrência de {days} de {months}} third{na terceira ocorrência de {days} de {months}} fourth{na quarta ocorrência de {days} de {months}} fifth{na quinta ocorrência de {days} de {months}} secondToLast{na penúltima ocorrência de {days} de {months}} last{na última ocorrência de {days} de {months}} other{em {days} de {months}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency} nos dias {days} de {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{na primeira ocorrência de {days} de {month}} second{na segunda ocorrência de {days} de {month}} third{na terceira ocorrência de {days} de {month}} fourth{na quarta ocorrência de {days} de {month}} fifth{na quinta ocorrência de {days} de {month}} secondToLast{na penúltima ocorrência de {days} de {month}} last{na última ocorrência de {days} de {month}} other{em {days} de {month}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } +} diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 953af511..20e04777 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,14 +1,31 @@ { "@@locale": "ru", + "windowsSupport": "Поддержка", + "windowsThirdPartyLicenses": "Лицензии сторонних компонентов", + "windowsSearch": "Поиск", + "windowsStartupDisabledByUser": "Отключено пользователем в параметрах Windows.", + "windowsStartupDisabledByPolicy": "Отключено политикой Windows.", + "windowsStartupUnavailable": "Доступно после установки BusyMax из пакета MSIX.", + "windowsReminderExitNotice": "Напоминания прекращаются после полного выхода из BusyMax. Оставьте приложение работать в фоновом режиме, чтобы получать их.", + "windowsProductVersionLabel": "Версия продукта", + "windowsPackageVersionLabel": "Версия пакета Windows", + "windowsUnpackaged": "Без пакета", + "windowsAgendaLoadMore": "Загрузить ещё элементы повестки", + "repeatWeeklyDaySummary": "{dayKey, select, MO{понедельникам} TU{вторникам} WE{средам} TH{четвергам} FR{пятницам} SA{субботам} SU{воскресеньям} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "{first} и {second} числа каждого месяца", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency}: {month}, {firstDay} и {secondDay} числа", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency}: {firstMonth} и {secondMonth}, {day} числа", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency}: {firstMonth} и {secondMonth}, {firstDay} и {secondDay} числа", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency}: {firstMonth} и {secondMonth}, {days} числа", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Подключите аккаунты Google, Microsoft, Apple iCloud Calendar или Nextcloud.", "googlePermissionsConsentNotice": "На экране запроса доступа Google установите флажки «Google Календарь» и «Google Задачи».", "googlePermissionsRequiredRetry": "BusyMax требуется доступ к Google Календарю и Google Задачам. Повторите попытку и установите оба флажка.", "finishSetup": "Завершить настройку", "continueSetup": "Продолжить", "onboardingSetupTitle": "Настройка BusyMax", "onboardingAccountsStepTitle": "Подключите аккаунты", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "Добавьте все нужные аккаунты. BusyMax синхронизирует поддерживаемые календари, события, списки задач и задачи из каждого аккаунта.", "onboardingPreferencesStepTitle": "Выберите системные параметры", "onboardingPreferencesStepDescription": "Настройте поведение приложения на рабочем столе, напоминания, уровень детализации уведомлений и внешний вид, прежде чем открыть расписание.", "signInWithGoogle": "Войти через Google", @@ -33,6 +50,43 @@ "calendarTasks": "Задачи", "calendar": "Календарь", "calendars": "Календари", + "newCalendar": "Новый календарь", + "calendarColor": "Цвет календаря", + "calendarColorOption": "Цвет {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "Этот провайдер не поддерживает управление календарями в BusyMax.", + "primaryCalendarCannotDelete": "Основной календарь нельзя удалить.", + "calendarCreateFailed": "Не удалось создать календарь: {error}", + "calendarCreatedRefreshPending": "Календарь создан, но BusyMax не смог обновить учётную запись. Он появится после следующей синхронизации.", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "Не удалось обновить календарь: {error}", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "Не удалось удалить календарь: {error}", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "Новое событие", "refreshCalendar": "Обновить календарь", "openInProvider": "Открыть в сервисе", @@ -52,6 +106,55 @@ "scheduleNoSearchResultsDescription": "Попробуйте изменить запрос или сбросить текущие фильтры.", "refresh": "Обновить", "trayOpenBusyMax": "Открыть BusyMax", + "trayShowBusyMax": "Показать BusyMax", + "trayNewEvent": "Новое событие…", + "trayNewTask": "Новая задача…", + "trayToday": "Сегодня", + "trayAllDay": "Весь день", + "trayNow": "Сейчас", + "trayCalendarEvent": "Событие календаря", + "trayUntitledEvent": "Событие без названия", + "trayNothingElseToday": "На сегодня больше ничего", + "trayTasksDueToday": "{count, plural, one{Сегодня истекает срок 1 задачи} few{Сегодня истекает срок {count} задач} many{Сегодня истекает срок {count} задач} other{Сегодня истекает срок {count} задачи}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Открыть расписание на сегодня", + "traySyncNow": "Синхронизировать сейчас", + "traySyncing": "Синхронизация…", + "trayNotConnected": "Не подключено", + "trayNotYetSynced": "Ещё не синхронизировано", + "trayLastSyncedJustNow": "Синхронизировано только что", + "trayLastSyncedMinutesAgo": "{count, plural, one{Синхронизировано минуту назад} few{Синхронизировано {count} минуты назад} many{Синхронизировано {count} минут назад} other{Синхронизировано {count} минуты назад}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, one{Синхронизировано час назад} few{Синхронизировано {count} часа назад} many{Синхронизировано {count} часов назад} other{Синхронизировано {count} часа назад}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, one{Синхронизировано день назад} few{Синхронизировано {count} дня назад} many{Синхронизировано {count} дней назад} other{Синхронизировано {count} дня назад}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Настройки", + "trayQuitBusyMax": "Выйти из BusyMax", "agendaLoadMoreOverdue": "Загрузить ещё просроченные задачи", "agendaLoadMoreNoDate": "Загрузить ещё задачи без даты", "viewDay": "День", @@ -154,7 +257,7 @@ "feedbackDetailedMessage": "Подробное сообщение", "feedbackReplyEmail": "Адрес электронной почты для ответа (необязательно)", "feedbackIncludeTechnicalDetails": "Включить технические сведения", - "feedbackTechnicalDetailsDisclosure": "Будут добавлены только версия Linux и выбранный язык приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не добавляются.", + "feedbackTechnicalDetailsDisclosure": "Будут добавлены только название и версия операционной системы и выбранный язык приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не добавляются.", "feedbackCategoryRequired": "Выберите категорию.", "feedbackSubjectLengthError": "Тема должна содержать от 3 до 120 символов.", "feedbackMessageLengthError": "Сообщение должно содержать от 10 до 5 000 символов.", @@ -180,7 +283,7 @@ "removingAccount": "Удаление аккаунта…", "removeAccountDescription": "Остановить синхронизацию и удалить данные этого аккаунта с устройства.", "removeAccountTitle": "Удалить {account} из BusyMax?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "С устройства будут удалены кэшированные задачи, календари, события, напоминания и ожидающие автономные изменения. Несинхронизированные изменения будут потеряны. Копии календарей, событий, списков задач и задач у поставщика не удаляются.", "revokeGoogleAccess": "Также отозвать у BusyMax доступ к этому аккаунту Google", "revokeGoogleAccessDescription": "Перед повторным подключением аккаунта потребуется снова предоставить доступ.", "removeAccountAction": "Удалить аккаунт", @@ -233,7 +336,7 @@ "primarySection": "Основные сведения", "statusSection": "Состояние", "openStatus": "Открыта", - "doneStatus": "Выполнена", + "doneStatus": "Выполнено", "notes": "Заметки", "dueDate": "Срок", "clearDueDate": "Удалить срок выполнения", @@ -291,7 +394,8 @@ "synced": "Синхронизировано", "account": "Аккаунт", "sync": "Синхронизация", - "manualFullSync": "Полная синхронизация вручную", + "forceFullResync": "Принудительная полная синхронизация", + "forceFullResyncDescription": "Полностью загружает заново данные всех подключённых аккаунтов. Используйте эту функцию только для устранения проблем с синхронизацией.", "runInBackgroundWhenClosed": "Продолжать работу после закрытия окна", "showTrayIcon": "Показывать значок в области уведомлений", "startMinimizedToTray": "Запускать свёрнутым в область уведомлений", @@ -305,6 +409,7 @@ "notifyConflicts": "Уведомлять о конфликтах", "notifyDueToday": "Уведомлять о задачах на сегодня", "eventReminders": "Напоминания о событиях", + "onState": "Вкл.", "taskReminders": "Напоминания о задачах", "notificationDetailLevel": "Содержимое уведомлений", "notificationDetailPrivate": "Скрывать подробности", @@ -314,9 +419,13 @@ "quietHoursStart": "Начало периода", "quietHoursEnd": "Конец периода", "notifications": "Уведомления", + "windowsNotificationsUnavailable": "Уведомления Windows недоступны", + "windowsNotificationsUnpackaged": "Эта незапакованная сборка для разработки не может использовать уведомления Windows. Для проверки напоминаний установите тестово подписанный MSIX.", + "windowsNotificationsInstalledFailure": "BusyMax не удалось инициализировать уведомления Windows. Напоминания не будут отображаться, пока проблема установки не будет устранена.", "appearance": "Внешний вид", "theme": "Тема", "themeSystem": "Системная", + "settingsSystem": "Система", "themeLight": "Светлая", "themeDark": "Тёмная", "themeFamily": "Семейство тем", @@ -352,7 +461,7 @@ "syncFailureNotificationTitle": "Сбой синхронизации BusyMax", "syncFailureNotificationBody": "Сбой фоновой синхронизации. {message}", "conflictNotificationTitle": "Конфликт синхронизации BusyMax", - "conflictNotificationBody": "Локальное изменение не удалось синхронизировать. {summary}", + "conflictNotificationBody": "Ожидающее локальное изменение заблокировано. {summary}", "dueTodayNotificationTitle": "Задачи на сегодня", "dueTodayNotificationBody": "{count, plural, one{Сегодня нужно выполнить {count} задачу.} few{Сегодня нужно выполнить {count} задачи.} many{Сегодня нужно выполнить {count} задач.} other{Сегодня нужно выполнить {count} задачи.}}", "eventReminderNotificationTitle": "Напоминание о событии", @@ -361,7 +470,7 @@ "taskReminderNotificationBody": "Срок выполнения задачи скоро наступит.", "notificationOpenAction": "Открыть", "notificationSnoozeAction": "Отложить на 10 минут", - "notificationDismissAction": "Отклонить", + "notificationDismissAction": "Закрыть", "notificationDetailsHidden": "Сведения скрыты настройками конфиденциальности.", "previousMonth": "Предыдущий месяц", "nextMonth": "Следующий месяц", @@ -374,52 +483,52 @@ "scheduleItemCount": "{count, plural, one{{count} запись} few{{count} записи} many{{count} записей} other{{count} записи}}", "readOnlyCalendar": "Этот календарь доступен только для чтения.", "selectTimeZone": "Выберите часовой пояс", - "searchLocations": "Поиск города", - "noLocationsFound": "Ничего не найдено", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "searchLocations": "Поиск мест", + "noLocationsFound": "Места не найдены", + "requiredField": "Это поле обязательно.", + "providerConnectionDescription": "Подключите календари и задачи одного из следующих поставщиков.", + "appleICloudProvider": "Календарь Apple iCloud", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Задачи Nextcloud", + "addAppleICloudAccount": "Добавить аккаунт Календаря Apple iCloud", + "addNextcloudAccount": "Добавить аккаунт Nextcloud", + "waitingForAppleICloud": "Подключение к Apple iCloud…", + "waitingForNextcloud": "Ожидание авторизации Nextcloud…", + "connectAppleICloudTitle": "Подключить Календарь Apple iCloud", + "appleAccountEmail": "Электронная почта аккаунта Apple", + "appleAppSpecificPassword": "Пароль для приложения", + "appleAppSpecificPasswordHelp": "Создайте пароль для приложения после включения двухфакторной аутентификации аккаунта Apple.", + "appleAppSpecificPasswordResetWarning": "Сброс пароля аккаунта Apple отзывает пароли для приложений.", + "connectNextcloudTitle": "Подключить Nextcloud", + "nextcloudServerUrl": "Сервер Nextcloud или адрес CalDAV", + "nextcloudServerUrlHelp": "Введите URL сервера Nextcloud или вставьте основной адрес CalDAV, скопированный из Nextcloud.", + "nextcloudBrowserAuthorizationHelp": "BusyMax откроет браузер. Разрешите доступ и вернитесь в BusyMax.", + "connectAccountAction": "Подключить", + "cancelAccountConnection": "Отменить подключение", + "nextcloudAccountRemovedRevokeFailed": "Аккаунт удалён локально, но пароль приложения Nextcloud не удалось отозвать.", + "davCachedOfflineNotice": "Данные календаря и задач кэшируются локально для работы без подключения.", + "davReauthenticationRequired": "Подключите аккаунт заново, чтобы возобновить синхронизацию.", + "davTemporarilyUnavailable": "Этот аккаунт временно недоступен.", + "davPermissionChanged": "Разрешения сервера изменились. Ожидающие изменения приостановлены.", + "davUnsupportedServer": "Этот сервер или профиль поставщика не поддерживается.", + "collectionSettings": "Календари и списки задач", + "calendarContent": "События календаря", + "taskContent": "Задачи", + "readOnlySharedCollection": "Только чтение", + "pendingLocally": "Ожидает локально", + "conflictBlocked": "Заблокировано из-за конфликта", + "authenticationBlocked": "Заблокировано до повторного подключения", + "operationFailed": "Операция не выполнена", + "keepServerVersion": "Сохранить версию сервера", + "reapplyLocalChange": "Проверить и повторно применить локальное изменение", + "duplicateLocalItem": "Дублировать как новый элемент", "deleteCalendarConfirmation": "Удалить «{title}»?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "Состояние подключения", + "davConnected": "Подключено", + "davConnecting": "Подключение…", + "davSignedOut": "Выполнен выход", + "davLastSuccessfulSync": "Последняя успешная синхронизация: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -427,9 +536,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "Ещё не синхронизировано", + "refreshCollections": "Обновить календари и списки задач", + "nextcloudServerHost": "Сервер: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -437,12 +546,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "Календарь событий", + "collectionSupportsTasks": "Список задач", + "collectionSupportsEventsAndTasks": "События и задачи", + "writableCollection": "Доступно для записи", + "sharedCollection": "Общий доступ", + "collectionLastSynced": "Последняя синхронизация: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -450,7 +559,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "Проблема синхронизации: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -458,8 +567,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "Конфликты синхронизации", + "remoteChangedAt": "Изменено на сервере: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -467,7 +576,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "Локальное изменение: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -475,144 +584,366 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "conflictResolutionFailed": "Не удалось разрешить конфликт.", + "recurringEventScope": "Область повторяющегося события", + "entireSeries": "Вся серия", + "singleOccurrence": "Это событие", + "thisAndFollowingEvents": "Это и последующие события", + "thisAndFutureUnavailable": "Не поддерживается этим поставщиком.", + "thisAndFutureMoveUnavailable": "Это и последующие события нельзя безопасно переместить. Выберите это событие или всю серию.", + "entireSeriesMoveUnavailable": "Правило повтора недоступно локально. Переместите только это событие.", + "copyEventAndDeleteOriginal": "Скопировать событие и удалить оригинал?", + "copyEventMoveWarning": "BusyMax не может напрямую переместить это событие из {source} в {destination}. Сначала будет создана копия, а оригинал будет удалён только после успешного копирования. Идентификаторы события изменятся; статусы ответов участников могут быть сброшены, а приглашения или отмены — отправлены; ссылки на конференции, вложения, напоминания, поля поставщика и исключения повтора могут не сохраниться.", + "copyAndDelete": "Скопировать и удалить", + "chooseRecurringEventScope": "Выберите, применять ли это изменение ко всей серии, только к этому событию или к этому и последующим событиям.", "taskDueBeforeStart": "Срок не может быть раньше начала.", "taskStartDueTimeModeMismatch": "Укажите время начала и срока или сделайте задачу на весь день.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", - "unshare": "Закрыть доступ", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "taskListCreateFailed": "Не удалось создать список задач: {error}", + "taskListRenameFailed": "Не удалось переименовать список задач: {error}", + "taskListDeleteFailed": "Не удалось удалить список задач: {error}", + "unshare": "Отменить общий доступ", + "readOnlyTaskListCannotRename": "Этот список задач доступен только для чтения и не может быть переименован.", + "taskListCannotDelete": "Этот список задач нельзя удалить с текущими разрешениями.", + "deleteTaskListConfirmation": "Удалить «{title}» и все его задачи?", + "unshareTaskListConfirmation": "Отменить общий доступ к «{title}» для этого аккаунта?", + "taskStatus": "Статус", + "taskStatusNone": "Нет статуса", "taskStatusNeedsAction": "Требуется действие", - "taskStatusInProcess": "Выполянется", - "taskStatusCompleted": "Завершённые", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusInProcess": "Выполняется", + "taskStatusCompleted": "Завершена", + "taskStatusCancelled": "Отменена", + "completionPercent": "Выполнено на {percent}%", + "completionDate": "Дата выполнения", "priority": "Приоритет", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "Напоминаний нет", - "editReminder": "Edit reminder", + "priorityNone": "Без приоритета", + "priorityHighValue": "Приоритет {priority} · высокий", + "priorityMediumValue": "Приоритет {priority} · средний", + "priorityLowValue": "Приоритет {priority} · низкий", + "taskUrl": "URL задачи", + "invalidTaskUrl": "Введите абсолютный URL вместе со схемой.", + "classification": "Классификация", + "classificationPublic": "При общем доступе показывать задачу полностью", + "classificationConfidential": "При общем доступе показывать только занятость", + "classificationPrivate": "При общем доступе скрывать эту задачу", + "pinTask": "Закрепить задачу", + "reminders": "Напоминания", + "noReminders": "Нет напоминаний", + "editReminder": "Изменить напоминание", "beforeTaskStarts": "До начала задачи", "beforeTaskDue": "До срока выполнения задачи", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Отменить напоминания", + "afterTaskStarts": "После начала задачи", + "afterTaskDue": "После срока выполнения задачи", + "relativeToTaskStart": "Относительно даты начала задачи", + "relativeToTaskDue": "Относительно срока выполнения задачи", + "reminderTimeOfDay": "Время суток", + "absoluteReminder": "В указанную дату и время", + "reminderAmount": "Количество", + "reminderUnitSeconds": "Секунды", + "reminderUnitMinutes": "Минуты", + "reminderUnitHours": "Часы", + "reminderUnitDays": "Дни", + "reminderUnitWeeks": "Недели", + "reminderAtTaskStart": "В момент начала задачи", + "reminderAtTaskDue": "В момент наступления срока задачи", + "unsupportedReminder": "Тип этого напоминания сохраняется, но его время нельзя изменить.", + "relatedRemindersTitle": "Сохранить связанные напоминания?", + "relatedRemindersDescription": "На эту дату приходится {count} связанных напоминаний. Сохранить их текущие дату и время?", + "discardRelatedReminders": "Удалить напоминания", "keepRelatedReminders": "Сохранить напоминания", - "repeatEvery": "Повторять каждые", - "repeatOn": "Repeat on", - "repeatEnd": "Прекратить повтор", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "в {days}", - "repeatOnMonthDaysSummary": " {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "в {months}", - "repeatTimesSummary": "{count} раз", + "repeatEvery": "Интервал", + "repeatOn": "Повторять в", + "repeatEnd": "Завершить повтор", + "repeatNever": "Никогда", + "repeatUntil": "В указанную дату", + "repeatAfter": "После указанного числа повторений", + "repeatCount": "Число повторений", + "repeatDayOfMonth": "Дни месяца", + "repeatMonths": "Месяцы", + "repeatOrdinal": "Порядок дня недели", + "repeatSpecificDays": "Определённые дни", + "repeatFirst": "Первый", + "repeatSecond": "Второй", + "repeatThird": "Третий", + "repeatFourth": "Четвёртый", + "repeatFifth": "Пятый", + "repeatSecondToLast": "Предпоследний", + "repeatLast": "Последний", + "repeatAnyDay": "День", + "repeatWeekday": "Будний день", + "repeatWeekendDay": "Выходной день", + "repeatOrdinalDaySummary": "{dayKey, select, MO{понедельник} TU{вторник} WE{среду} TH{четверг} FR{пятницу} SA{субботу} SU{воскресенье} day{день} weekday{будний день} weekend{выходной день} other{{day}}}", + "repeatEveryDays": "Каждые {count} дн.", + "repeatEveryWeeks": "Каждые {count} нед.", + "repeatEveryMonths": "Каждые {count} мес.", + "repeatEveryYears": "Каждые {count} г.", + "repeatOnDaysSummary": "по {days}", + "repeatOnMonthDaysSummary": "{days} числа месяца", + "repeatOnOrdinalSummary": "{position, select, first{в первый {days}} second{во второй {days}} third{в третий {days}} fourth{в четвёртый {days}} fifth{в пятый {days}} secondToLast{в предпоследний {days}} last{в последний {days}} other{в {days}}}", + "repeatInMonthsSummary": "в месяцы {months}", + "repeatTimesSummary": "{count, plural, one{{count} раз} few{{count} раза} many{{count} раз} other{{count} раза}}", "repeatUntilSummary": "до {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "unsupportedRecurrencePreserved": "Это правило повторения использует параметры, которые этот редактор не изменяет.", + "recurrenceUnsupportedByProvider": "Это повторение нельзя использовать с {provider}.", "duplicateTask": "Дублировать задачу", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", - "hideSubtasks": "Скрыть вложенные задачи", + "taskDuplicated": "Задача продублирована.", + "taskDuplicateFailed": "Не удалось продублировать задачу: {error}", + "hideSubtasks": "Скрыть подзадачи", "hideClosedSubtasks": "Скрыть закрытые подзадачи", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "reminderUnit": "Единица", + "attendeeRequired": "Обязательно", + "attendeeOptional": "Необязательно", + "meetingSection": "Встреча", + "addGoogleMeet": "Добавить Google Meet", + "addTeamsMeeting": "Добавить встречу Microsoft Teams", + "onlineMeetingAdded": "Онлайн-встреча добавлена", + "requestResponses": "Запрашивать ответы", + "requestResponsesDescription": "Попросите гостей ответить на приглашение.", + "hideGuestList": "Скрыть список гостей", + "hideGuestListDescription": "Гости не видят других приглашённых.", + "allowNewTimeProposals": "Разрешить новые предложения времени", + "allowNewTimeProposalsDescription": "Гости могут предложить другое время встречи.", + "notifyGuestsTitle": "Уведомить гостей?", + "notifyGuestsSaveMessage": "На встрече есть гости. Отправить приглашения или обновления события при сохранении?", + "notifyGuestsDeleteMessage": "На встрече есть гости. Отправить отмену при удалении?", + "sendUpdates": "Отправить обновления", + "sendCancellation": "Отправить отмену", + "doNotSend": "Не отправлять", + "microsoftNotifyGuestsSaveTitle": "Сохранить встречу?", + "microsoftNotifyGuestsSaveMessage": "Microsoft отправит гостям приглашения или обновления события.", + "microsoftNotifyGuestsDeleteTitle": "Удалить встречу?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft отправит гостям отмену.", + "organizer": "Организатор", + "yourResponse": "Ваш ответ", + "guestResponses": "Ответы гостей", + "respond": "Ответить", + "acceptInvitation": "Принять", + "tentativeInvitation": "Под вопросом", + "declineInvitation": "Отклонить", + "joinMeeting": "Присоединиться к встрече", + "responseAccepted": "Принято", + "responseTentative": "Под вопросом", + "responseDeclined": "Отклонено", + "responseNeedsAction": "Ожидается ответ", + "responseNotResponded": "Нет ответа", + "responseOrganizer": "Организатор", + "invitationResponseFailed": "Не удалось отправить ответ: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "Не удалось открыть ссылку на встречу.", + "setCustomCalendarName": "Задать своё название", + "setAction": "Задать", + "removeFromMyCalendars": "Удалить из моих календарей", + "removeAction": "Удалить", + "removeCalendarConfirmation": "Удалить «{title}» из вашего списка Google Календаря? Общий календарь и его события удалены не будут.", + "calendarCannotRemove": "Этот календарь нельзя удалить или убрать из аккаунта.", + "calendarPendingChangesPreventRemoval": "Дождитесь синхронизации ожидающих изменений этого календаря перед удалением или отключением.", + "calendarSubscriptions": "Подписки на календари", + "calendarSubscriptionsDescription": "Добавляйте календари только для чтения, обновляемые по защищённому URL WebCal.", + "addCalendarSubscription": "Добавить подписку на календарь", + "subscriptionName": "Локальное имя", + "subscriptionUrl": "URL подписки", + "subscriptionUrlHelp": "Введите HTTPS- или webcal-URL. BusyMax хранит полный URL в защищённом хранилище.", + "subscriptionUrlInvalid": "Введите действительный HTTPS- или webcal-URL без данных пользователя или фрагмента.", + "subscriptionColor": "Локальный цвет", + "subscriptionColorHelp": "Используйте шестизначный цвет, например #3584E4.", + "subscriptionColorInvalid": "Введите шестизначный шестнадцатеричный цвет.", + "subscriptionRefreshMode": "Частота обновления", + "subscriptionAutomatic": "Автоматически", + "subscriptionHourly": "Ежечасно", + "subscriptionSixHours": "Каждые шесть часов", + "subscriptionDaily": "Ежедневно", + "subscriptionSafeOrigin": "Источник: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Введите действительный URL для просмотра безопасного источника.", + "subscriptionReadOnly": "Подписка только для чтения", + "subscriptionNeverRefreshed": "Ещё не обновлялось", + "subscriptionLastRefresh": "Последнее успешное обновление: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Следующее обновление: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Актуально", + "subscriptionStatusIssue": "Проблема обновления: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Обновить сейчас", + "unsubscribe": "Отменить подписку", + "unsubscribeCalendarTitle": "Отменить подписку на «{name}»?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "Локальная подписка и кэшированные события будут удалены. Опубликованный календарь не изменится.", + "addSubscriptionAction": "Добавить подписку", + "subscriptionOperationFailed": "Ошибка подписки на календарь: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Подписки", + "calendarImport": "Импорт календаря", + "calendarImportDescription": "Выберите файл, просмотрите его события, а затем выберите доступный для записи календарь, который должен их принять.", + "importIcsFile": "Импортировать файл .ics", + "importIcsPreview": "Импортировать события календаря", + "importEventsFound": "Наборы импортируемых событий: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Недопустимые события: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Намеренно пропущено: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "Нет доступного календаря назначения с правом записи.", + "importDestinationCalendar": "Календарь назначения", + "importIcsConfirm": "Импортировать события", + "importIcsComplete": "Импорт завершён", + "importQueued": "Импортировано или поставлено в очередь: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Пропущено дубликатов: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Неподдерживаемые наборы повторений: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "Не удалось импортировать файл календаря: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "Нет подключения", "networkOfflineDescription": "Изменения будут синхронизированы после восстановления подключения.", - "networkOfflineTryAgain": "Нет подключения к сети. Подключитесь к Интернету и повторите попытку." + "networkOfflineTryAgain": "Нет подключения к сети. Подключитесь к Интернету и повторите попытку.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "в дни месяца {days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}-го", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first} и {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} и {second}", + "repeatMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} и {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} и {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency}: {month}, {day} числа", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency}: {months}, {day} числа", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency}: {months}, {days} числа", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency}: {months}, {position, select, first{в первый {days}} second{во второй {days}} third{в третий {days}} fourth{в четвёртый {days}} fifth{в пятый {days}} secondToLast{в предпоследний {days}} last{в последний {days}} other{в {days}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency}: {month}, {days} числа", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency}: {month}, {position, select, first{в первый {days}} second{во второй {days}} third{в третий {days}} fourth{в четвёртый {days}} fifth{в пятый {days}} secondToLast{в предпоследний {days}} last{в последний {days}} other{в {days}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 6d935b89..2194bcef 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1,14 +1,31 @@ { "@@locale": "vi", + "windowsSupport": "Hỗ trợ", + "windowsThirdPartyLicenses": "Giấy phép bên thứ ba", + "windowsSearch": "Tìm kiếm", + "windowsStartupDisabledByUser": "Người dùng đã tắt trong Cài đặt Windows.", + "windowsStartupDisabledByPolicy": "Đã tắt theo chính sách Windows.", + "windowsStartupUnavailable": "Khả dụng sau khi cài đặt BusyMax từ gói MSIX.", + "windowsReminderExitNotice": "Lời nhắc sẽ dừng khi thoát hẳn BusyMax. Hãy để ứng dụng chạy trong nền để nhận lời nhắc.", + "windowsProductVersionLabel": "Phiên bản sản phẩm", + "windowsPackageVersionLabel": "Phiên bản gói Windows", + "windowsUnpackaged": "Chưa đóng gói", + "windowsAgendaLoadMore": "Tải thêm mục lịch biểu", + "repeatWeeklyDaySummary": "{dayKey, select, MO{Thứ Hai} TU{Thứ Ba} WE{Thứ Tư} TH{Thứ Năm} FR{Thứ Sáu} SA{Thứ Bảy} SU{Chủ Nhật} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "vào các ngày {first} và {second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} vào các ngày {firstDay} và {secondDay} của {month}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} vào ngày {day} của {firstMonth} và {secondMonth}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} vào các ngày {firstDay} và {secondDay} của {firstMonth} và {secondMonth}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} vào các ngày {days} của {firstMonth} và {secondMonth}", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Kết nối tài khoản Google, Microsoft, Apple iCloud Calendar hoặc Nextcloud.", "googlePermissionsConsentNotice": "Trên màn hình cấp quyền của Google, hãy chọn cả quyền truy cập Lịch và Công việc.", "googlePermissionsRequiredRetry": "Cần có quyền truy cập Google Calendar và Google Tasks. Vui lòng thử lại và chọn cả hai hộp kiểm.", "finishSetup": "Hoàn tất thiết lập", "continueSetup": "Tiếp tục", "onboardingSetupTitle": "Thiết lập BusyMax", "onboardingAccountsStepTitle": "Kết nối tài khoản", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "Thêm mọi tài khoản bạn muốn sử dụng. BusyMax đồng bộ lịch, sự kiện, danh sách công việc và công việc được hỗ trợ từ từng tài khoản.", "onboardingPreferencesStepTitle": "Chọn cài đặt hệ thống", "onboardingPreferencesStepDescription": "Thiết lập cách ứng dụng hoạt động trên máy tính, lời nhắc, mức độ chi tiết của thông báo và giao diện trước khi mở lịch biểu.", "signInWithGoogle": "Đăng nhập bằng Google", @@ -33,6 +50,43 @@ "calendarTasks": "Công việc", "calendar": "Lịch", "calendars": "Lịch", + "newCalendar": "Lịch mới", + "calendarColor": "Màu lịch", + "calendarColorOption": "Màu {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "Nhà cung cấp này không hỗ trợ quản lý lịch trong BusyMax.", + "primaryCalendarCannotDelete": "Không thể xóa lịch chính.", + "calendarCreateFailed": "Không thể tạo lịch: {error}", + "calendarCreatedRefreshPending": "Lịch đã được tạo nhưng BusyMax không thể làm mới tài khoản. Lịch sẽ xuất hiện sau lần đồng bộ tiếp theo.", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "Không thể cập nhật lịch: {error}", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "Không thể xóa lịch: {error}", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "Sự kiện mới", "refreshCalendar": "Làm mới lịch", "openInProvider": "Mở trong dịch vụ", @@ -52,6 +106,55 @@ "scheduleNoSearchResultsDescription": "Thử tìm kiếm khác hoặc xóa các bộ lọc hiện tại.", "refresh": "Làm mới", "trayOpenBusyMax": "Mở BusyMax", + "trayShowBusyMax": "Hiển thị BusyMax", + "trayNewEvent": "Sự kiện mới…", + "trayNewTask": "Công việc mới…", + "trayToday": "Hôm nay", + "trayAllDay": "Cả ngày", + "trayNow": "Bây giờ", + "trayCalendarEvent": "Sự kiện lịch", + "trayUntitledEvent": "Sự kiện chưa có tiêu đề", + "trayNothingElseToday": "Hôm nay không còn gì khác", + "trayTasksDueToday": "{count, plural, =1{1 công việc đến hạn hôm nay} other{{count} công việc đến hạn hôm nay}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Mở lịch hôm nay", + "traySyncNow": "Đồng bộ ngay", + "traySyncing": "Đang đồng bộ…", + "trayNotConnected": "Chưa kết nối", + "trayNotYetSynced": "Chưa đồng bộ", + "trayLastSyncedJustNow": "Vừa đồng bộ", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Đồng bộ 1 phút trước} other{Đồng bộ {count} phút trước}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Đồng bộ 1 giờ trước} other{Đồng bộ {count} giờ trước}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Đồng bộ 1 ngày trước} other{Đồng bộ {count} ngày trước}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Cài đặt", + "trayQuitBusyMax": "Thoát BusyMax", "agendaLoadMoreOverdue": "Tải thêm công việc quá hạn", "agendaLoadMoreNoDate": "Tải thêm công việc không có ngày", "viewDay": "Ngày", @@ -78,7 +181,7 @@ "doesNotRepeat": "Không lặp lại", "defaultReminder": "Lời nhắc mặc định", "guests": "Khách mời", - "noGuests": "Không có khách mời", + "noGuests": "Không có khách", "description": "Mô tả", "availabilityShowAs": "Tình trạng rảnh/bận / Hiển thị là", "busy": "Bận", @@ -154,7 +257,7 @@ "feedbackDetailedMessage": "Nội dung chi tiết", "feedbackReplyEmail": "Địa chỉ email để nhận phản hồi (không bắt buộc)", "feedbackIncludeTechnicalDetails": "Bao gồm chi tiết kỹ thuật", - "feedbackTechnicalDetailsDisclosure": "Chỉ thêm phiên bản hệ điều hành Linux và ngôn ngữ, khu vực của ứng dụng. Không bao gồm nhật ký, dữ liệu tài khoản, tên tệp hoặc thông tin chẩn đoán khác.", + "feedbackTechnicalDetailsDisclosure": "Chỉ thêm tên và phiên bản hệ điều hành cùng ngôn ngữ, khu vực của ứng dụng. Không bao gồm nhật ký, dữ liệu tài khoản, tên tệp hoặc thông tin chẩn đoán khác.", "feedbackCategoryRequired": "Hãy chọn một danh mục.", "feedbackSubjectLengthError": "Chủ đề phải có từ 3 đến 120 ký tự.", "feedbackMessageLengthError": "Nội dung phải có từ 10 đến 5.000 ký tự.", @@ -180,7 +283,7 @@ "removingAccount": "Đang xóa tài khoản…", "removeAccountDescription": "Dừng đồng bộ và xóa dữ liệu của tài khoản này khỏi thiết bị.", "removeAccountTitle": "Xóa {account} khỏi BusyMax?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "Thao tác này xóa khỏi thiết bị các công việc, lịch, sự kiện, lời nhắc và thay đổi ngoại tuyến đang chờ được lưu trong bộ nhớ đệm. Các thay đổi chưa đồng bộ sẽ mất. Bản sao lịch, sự kiện, danh sách công việc và công việc ở nhà cung cấp không bị xóa.", "revokeGoogleAccess": "Đồng thời thu hồi quyền truy cập của BusyMax vào tài khoản Google này", "revokeGoogleAccessDescription": "Bạn sẽ cần cấp lại quyền truy cập trước khi kết nối lại.", "removeAccountAction": "Xóa tài khoản", @@ -233,7 +336,7 @@ "primarySection": "Chính", "statusSection": "Trạng thái", "openStatus": "Chưa hoàn thành", - "doneStatus": "Đã hoàn thành", + "doneStatus": "Đã xong", "notes": "Ghi chú", "dueDate": "Ngày đến hạn", "clearDueDate": "Xóa ngày đến hạn", @@ -282,7 +385,7 @@ "id": "ID", "etag": "ETag", "updated": "Đã cập nhật", - "parent": "Công việc cha", + "parent": "Công việc chính", "position": "Vị trí", "webLink": "Liên kết web", "assignment": "Phân công", @@ -291,7 +394,8 @@ "synced": "Đã đồng bộ", "account": "Tài khoản", "sync": "Đồng bộ", - "manualFullSync": "Đồng bộ toàn bộ thủ công", + "forceFullResync": "Buộc đồng bộ hóa lại toàn bộ", + "forceFullResyncDescription": "Tải lại toàn bộ dữ liệu từ mọi tài khoản đã kết nối. Chỉ sử dụng tùy chọn này để khắc phục sự cố đồng bộ hóa.", "runInBackgroundWhenClosed": "Tiếp tục chạy khi đóng cửa sổ", "showTrayIcon": "Hiện biểu tượng khay hệ thống", "startMinimizedToTray": "Khởi động thu nhỏ vào khay hệ thống", @@ -305,6 +409,7 @@ "notifyConflicts": "Thông báo khi có xung đột", "notifyDueToday": "Thông báo công việc đến hạn hôm nay", "eventReminders": "Lời nhắc sự kiện", + "onState": "Bật", "taskReminders": "Lời nhắc công việc", "notificationDetailLevel": "Mức độ chi tiết của thông báo", "notificationDetailPrivate": "Riêng tư", @@ -314,9 +419,13 @@ "quietHoursStart": "Bắt đầu giờ yên tĩnh", "quietHoursEnd": "Kết thúc giờ yên tĩnh", "notifications": "Thông báo", + "windowsNotificationsUnavailable": "Thông báo Windows không khả dụng", + "windowsNotificationsUnpackaged": "Bản chạy phát triển chưa đóng gói này không thể dùng thông báo Windows. Hãy cài đặt MSIX được ký để thử nghiệm nhằm kiểm tra lời nhắc.", + "windowsNotificationsInstalledFailure": "BusyMax không thể khởi tạo thông báo Windows. Lời nhắc sẽ không xuất hiện cho đến khi sự cố cài đặt này được khắc phục.", "appearance": "Giao diện", "theme": "Chủ đề", "themeSystem": "Hệ thống", + "settingsSystem": "Hệ thống", "themeLight": "Sáng", "themeDark": "Tối", "themeFamily": "Họ chủ đề", @@ -361,7 +470,7 @@ "taskReminderNotificationBody": "Công việc sắp đến hạn.", "notificationOpenAction": "Mở", "notificationSnoozeAction": "Báo lại sau 10 phút", - "notificationDismissAction": "Bỏ qua", + "notificationDismissAction": "Đóng", "notificationDetailsHidden": "Chi tiết bị ẩn theo cài đặt quyền riêng tư.", "previousMonth": "Tháng trước", "nextMonth": "Tháng sau", @@ -376,50 +485,50 @@ "selectTimeZone": "Chọn múi giờ", "searchLocations": "Tìm kiếm địa điểm", "noLocationsFound": "Không tìm thấy địa điểm", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "Trường này là bắt buộc.", + "providerConnectionDescription": "Kết nối lịch và công việc từ một trong các nhà cung cấp sau.", + "appleICloudProvider": "Lịch Apple iCloud", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Công việc Nextcloud", + "addAppleICloudAccount": "Thêm tài khoản Lịch Apple iCloud", + "addNextcloudAccount": "Thêm tài khoản Nextcloud", + "waitingForAppleICloud": "Đang kết nối với Apple iCloud…", + "waitingForNextcloud": "Đang chờ ủy quyền Nextcloud…", + "connectAppleICloudTitle": "Kết nối Lịch Apple iCloud", + "appleAccountEmail": "Email tài khoản Apple", + "appleAppSpecificPassword": "Mật khẩu dành riêng cho ứng dụng", + "appleAppSpecificPasswordHelp": "Tạo mật khẩu dành riêng cho ứng dụng sau khi bật xác thực hai yếu tố cho tài khoản Apple.", + "appleAppSpecificPasswordResetWarning": "Đặt lại mật khẩu tài khoản Apple sẽ thu hồi các mật khẩu dành riêng cho ứng dụng.", + "connectNextcloudTitle": "Kết nối Nextcloud", + "nextcloudServerUrl": "Máy chủ Nextcloud hoặc địa chỉ CalDAV", + "nextcloudServerUrlHelp": "Nhập URL máy chủ Nextcloud hoặc dán địa chỉ CalDAV chính được sao chép từ Nextcloud.", + "nextcloudBrowserAuthorizationHelp": "BusyMax sẽ mở trình duyệt. Phê duyệt quyền truy cập ở đó rồi quay lại BusyMax.", + "connectAccountAction": "Kết nối", + "cancelAccountConnection": "Hủy kết nối", + "nextcloudAccountRemovedRevokeFailed": "Tài khoản đã bị xóa cục bộ nhưng không thể thu hồi mật khẩu ứng dụng Nextcloud.", + "davCachedOfflineNotice": "Dữ liệu lịch và công việc được lưu trong bộ nhớ đệm cục bộ để sử dụng ngoại tuyến.", + "davReauthenticationRequired": "Kết nối lại tài khoản này để tiếp tục đồng bộ.", + "davTemporarilyUnavailable": "Tài khoản này tạm thời không khả dụng.", + "davPermissionChanged": "Quyền máy chủ đã thay đổi. Các chỉnh sửa đang chờ bị tạm dừng.", + "davUnsupportedServer": "Máy chủ hoặc hồ sơ nhà cung cấp này không được hỗ trợ.", + "collectionSettings": "Lịch và danh sách công việc", + "calendarContent": "Sự kiện lịch", + "taskContent": "Công việc", + "readOnlySharedCollection": "Chỉ đọc", + "pendingLocally": "Đang chờ cục bộ", + "conflictBlocked": "Bị chặn do xung đột", + "authenticationBlocked": "Bị chặn cho đến khi kết nối lại", + "operationFailed": "Thao tác không thành công", + "keepServerVersion": "Giữ phiên bản máy chủ", + "reapplyLocalChange": "Xem lại và áp dụng lại thay đổi cục bộ", + "duplicateLocalItem": "Nhân bản thành mục mới", "deleteCalendarConfirmation": "Xóa “{title}”?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "Trạng thái kết nối", + "davConnected": "Đã kết nối", + "davConnecting": "Đang kết nối…", + "davSignedOut": "Đã đăng xuất", + "davLastSuccessfulSync": "Lần đồng bộ thành công gần nhất: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -427,9 +536,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "Chưa đồng bộ", + "refreshCollections": "Làm mới lịch và danh sách công việc", + "nextcloudServerHost": "Máy chủ: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -437,12 +546,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "Lịch sự kiện", + "collectionSupportsTasks": "Danh sách công việc", + "collectionSupportsEventsAndTasks": "Sự kiện và công việc", + "writableCollection": "Có thể ghi", + "sharedCollection": "Đã chia sẻ", + "collectionLastSynced": "Đồng bộ lần cuối: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -450,7 +559,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "Sự cố đồng bộ: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -458,8 +567,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "Xung đột đồng bộ", + "remoteChangedAt": "Máy chủ đã thay đổi: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -467,7 +576,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "Chỉnh sửa cục bộ: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -475,144 +584,366 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "conflictResolutionFailed": "Không thể giải quyết xung đột.", + "recurringEventScope": "Phạm vi sự kiện lặp lại", + "entireSeries": "Toàn bộ chuỗi", + "singleOccurrence": "Sự kiện này", + "thisAndFollowingEvents": "Sự kiện này và các sự kiện tiếp theo", + "thisAndFutureUnavailable": "Nhà cung cấp này không hỗ trợ.", + "thisAndFutureMoveUnavailable": "Không thể di chuyển an toàn sự kiện này và các sự kiện tiếp theo. Hãy chọn sự kiện này hoặc toàn bộ chuỗi.", + "entireSeriesMoveUnavailable": "Quy tắc lặp lại không có sẵn trên thiết bị. Hãy chỉ di chuyển sự kiện này.", + "copyEventAndDeleteOriginal": "Sao chép sự kiện và xóa bản gốc?", + "copyEventMoveWarning": "BusyMax không thể di chuyển trực tiếp sự kiện này từ {source} sang {destination}. Ứng dụng sẽ tạo bản sao trước và chỉ xóa bản gốc sau khi sao chép thành công. ID sự kiện sẽ thay đổi; trạng thái phản hồi của người tham dự có thể bị đặt lại và lời mời hoặc thông báo hủy có thể được gửi; liên kết cuộc họp, tệp đính kèm, lời nhắc, trường riêng của nhà cung cấp và ngoại lệ lặp lại có thể không được chuyển sang.", + "copyAndDelete": "Sao chép và xóa", + "chooseRecurringEventScope": "Chọn áp dụng thay đổi này cho toàn bộ chuỗi, chỉ sự kiện này hay sự kiện này và các sự kiện tiếp theo.", "taskDueBeforeStart": "Hạn chót không được trước thời gian bắt đầu.", "taskStartDueTimeModeMismatch": "Đặt giờ cho cả thời gian bắt đầu và hạn chót, hoặc đặt công việc là cả ngày.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", - "unshare": "Bỏ chia sẽ", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", - "taskStatusNeedsAction": "Needs action", - "taskStatusInProcess": "In process", - "taskStatusCompleted": "Hoàn thành", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", - "priority": "Priority", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "No reminders", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Before the task starts", - "beforeTaskDue": "Before the task is due", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "Lặp lại mỗi", - "repeatOn": "Repeat on", - "repeatEnd": "Kết thúc lập lại", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", - "repeatOnMonthDaysSummary": "on day {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", - "repeatTimesSummary": "{count} lần", - "repeatUntilSummary": "cho đến {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", - "hideSubtasks": "Hide subtasks", - "hideClosedSubtasks": "Hide closed subtasks", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "taskListCreateFailed": "Không thể tạo danh sách công việc: {error}", + "taskListRenameFailed": "Không thể đổi tên danh sách công việc: {error}", + "taskListDeleteFailed": "Không thể xóa danh sách công việc: {error}", + "unshare": "Ngừng chia sẻ", + "readOnlyTaskListCannotRename": "Danh sách công việc này ở chế độ chỉ đọc và không thể đổi tên.", + "taskListCannotDelete": "Bạn không thể xóa danh sách công việc này với quyền hiện tại.", + "deleteTaskListConfirmation": "Xóa “{title}” và tất cả công việc trong đó?", + "unshareTaskListConfirmation": "Ngừng chia sẻ “{title}” khỏi tài khoản này?", + "taskStatus": "Trạng thái", + "taskStatusNone": "Không có trạng thái", + "taskStatusNeedsAction": "Cần xử lý", + "taskStatusInProcess": "Đang thực hiện", + "taskStatusCompleted": "Đã hoàn thành", + "taskStatusCancelled": "Đã hủy", + "completionPercent": "Đã hoàn thành {percent}%", + "completionDate": "Ngày hoàn thành", + "priority": "Mức độ ưu tiên", + "priorityNone": "Không có mức độ ưu tiên", + "priorityHighValue": "Ưu tiên {priority} · Cao", + "priorityMediumValue": "Ưu tiên {priority} · Trung bình", + "priorityLowValue": "Ưu tiên {priority} · Thấp", + "taskUrl": "URL công việc", + "invalidTaskUrl": "Nhập URL tuyệt đối, bao gồm lược đồ.", + "classification": "Phân loại", + "classificationPublic": "Khi chia sẻ, hiển thị toàn bộ công việc", + "classificationConfidential": "Khi chia sẻ, chỉ hiển thị trạng thái bận", + "classificationPrivate": "Khi chia sẻ, ẩn công việc này", + "pinTask": "Ghim công việc", + "reminders": "Lời nhắc", + "noReminders": "Không có lời nhắc", + "editReminder": "Chỉnh sửa lời nhắc", + "beforeTaskStarts": "Trước khi công việc bắt đầu", + "beforeTaskDue": "Trước hạn chót của công việc", + "afterTaskStarts": "Sau khi công việc bắt đầu", + "afterTaskDue": "Sau hạn chót của công việc", + "relativeToTaskStart": "Theo ngày bắt đầu công việc", + "relativeToTaskDue": "Theo ngày đến hạn công việc", + "reminderTimeOfDay": "Thời gian trong ngày", + "absoluteReminder": "Vào ngày và giờ cụ thể", + "reminderAmount": "Số lượng", + "reminderUnitSeconds": "Giây", + "reminderUnitMinutes": "Phút", + "reminderUnitHours": "Giờ", + "reminderUnitDays": "Ngày", + "reminderUnitWeeks": "Tuần", + "reminderAtTaskStart": "Khi công việc bắt đầu", + "reminderAtTaskDue": "Khi công việc đến hạn", + "unsupportedReminder": "Loại lời nhắc này được giữ lại nhưng không thể chỉnh sửa thời gian.", + "relatedRemindersTitle": "Giữ lời nhắc liên quan?", + "relatedRemindersDescription": "Ngày này có {count} lời nhắc liên quan. Giữ chúng ở ngày và giờ hiện tại?", + "discardRelatedReminders": "Xóa lời nhắc", + "keepRelatedReminders": "Giữ lời nhắc", + "repeatEvery": "Khoảng lặp lại", + "repeatOn": "Lặp lại vào", + "repeatEnd": "Kết thúc lặp lại", + "repeatNever": "Không bao giờ", + "repeatUntil": "Vào ngày", + "repeatAfter": "Sau một số lần xuất hiện", + "repeatCount": "Số lần lặp", + "repeatDayOfMonth": "Ngày trong tháng", + "repeatMonths": "Tháng", + "repeatOrdinal": "Vị trí ngày trong tuần", + "repeatSpecificDays": "Ngày cụ thể", + "repeatFirst": "Thứ nhất", + "repeatSecond": "Thứ hai", + "repeatThird": "Thứ ba", + "repeatFourth": "Thứ tư", + "repeatFifth": "Thứ năm", + "repeatSecondToLast": "Áp chót", + "repeatLast": "Cuối cùng", + "repeatAnyDay": "Ngày", + "repeatWeekday": "Ngày trong tuần", + "repeatWeekendDay": "Ngày cuối tuần", + "repeatOrdinalDaySummary": "{dayKey, select, MO{Thứ Hai} TU{Thứ Ba} WE{Thứ Tư} TH{Thứ Năm} FR{Thứ Sáu} SA{Thứ Bảy} SU{Chủ Nhật} day{ngày} weekday{ngày trong tuần} weekend{ngày cuối tuần} other{{day}}}", + "repeatEveryDays": "Mỗi {count} ngày", + "repeatEveryWeeks": "Mỗi {count} tuần", + "repeatEveryMonths": "Mỗi {count} tháng", + "repeatEveryYears": "Mỗi {count} năm", + "repeatOnDaysSummary": "vào {days}", + "repeatOnMonthDaysSummary": "vào ngày {days} trong tháng", + "repeatOnOrdinalSummary": "{position, select, first{vào {days} đầu tiên} second{vào {days} thứ hai} third{vào {days} thứ ba} fourth{vào {days} thứ tư} fifth{vào {days} thứ năm} secondToLast{vào {days} áp chót} last{vào {days} cuối cùng} other{vào {days}}}", + "repeatInMonthsSummary": "vào {months}", + "repeatTimesSummary": "{count, plural, other{{count} lần}}", + "repeatUntilSummary": "đến {date}", + "unsupportedRecurrencePreserved": "Quy tắc lặp lại này sử dụng các tùy chọn mà trình chỉnh sửa này không thay đổi.", + "recurrenceUnsupportedByProvider": "Không thể sử dụng kiểu lặp lại này với {provider}.", + "duplicateTask": "Nhân bản công việc", + "taskDuplicated": "Đã nhân bản công việc.", + "taskDuplicateFailed": "Không thể nhân bản công việc: {error}", + "hideSubtasks": "Ẩn công việc phụ", + "hideClosedSubtasks": "Ẩn công việc phụ đã đóng", + "reminderUnit": "Đơn vị", + "attendeeRequired": "Bắt buộc", + "attendeeOptional": "Tùy chọn", + "meetingSection": "Cuộc họp", + "addGoogleMeet": "Thêm Google Meet", + "addTeamsMeeting": "Thêm cuộc họp Microsoft Teams", + "onlineMeetingAdded": "Đã thêm cuộc họp trực tuyến", + "requestResponses": "Yêu cầu phản hồi", + "requestResponsesDescription": "Yêu cầu khách trả lời lời mời.", + "hideGuestList": "Ẩn danh sách khách", + "hideGuestListDescription": "Khách không thể xem những người khác được mời.", + "allowNewTimeProposals": "Cho phép đề xuất thời gian mới", + "allowNewTimeProposalsDescription": "Khách có thể đề xuất thời gian họp khác.", + "notifyGuestsTitle": "Thông báo cho khách?", + "notifyGuestsSaveMessage": "Cuộc họp này có khách. Gửi lời mời hoặc cập nhật sự kiện khi lưu?", + "notifyGuestsDeleteMessage": "Cuộc họp này có khách. Gửi thông báo hủy khi xóa?", + "sendUpdates": "Gửi cập nhật", + "sendCancellation": "Gửi thông báo hủy", + "doNotSend": "Không gửi", + "microsoftNotifyGuestsSaveTitle": "Lưu cuộc họp?", + "microsoftNotifyGuestsSaveMessage": "Microsoft sẽ gửi lời mời hoặc cập nhật sự kiện cho khách.", + "microsoftNotifyGuestsDeleteTitle": "Xóa cuộc họp?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft sẽ gửi thông báo hủy cho khách.", + "organizer": "Người tổ chức", + "yourResponse": "Phản hồi của bạn", + "guestResponses": "Phản hồi của khách", + "respond": "Phản hồi", + "acceptInvitation": "Chấp nhận", + "tentativeInvitation": "Tạm thời", + "declineInvitation": "Từ chối", + "joinMeeting": "Tham gia cuộc họp", + "responseAccepted": "Đã chấp nhận", + "responseTentative": "Tạm thời", + "responseDeclined": "Đã từ chối", + "responseNeedsAction": "Đang chờ phản hồi", + "responseNotResponded": "Chưa phản hồi", + "responseOrganizer": "Người tổ chức", + "invitationResponseFailed": "Không thể gửi phản hồi: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "Không thể mở liên kết cuộc họp.", + "setCustomCalendarName": "Đặt tên tùy chỉnh", + "setAction": "Đặt", + "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.", + "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.", + "calendarSubscriptions": "Gói đăng ký lịch", + "calendarSubscriptionsDescription": "Thêm lịch chỉ đọc được làm mới từ URL WebCal bảo mật.", + "addCalendarSubscription": "Thêm gói đăng ký lịch", + "subscriptionName": "Tên cục bộ", + "subscriptionUrl": "URL gói đăng ký", + "subscriptionUrlHelp": "Nhập URL HTTPS hoặc webcal. BusyMax giữ URL đầy đủ trong bộ nhớ an toàn.", + "subscriptionUrlInvalid": "Nhập URL HTTPS hoặc webcal hợp lệ không có thông tin người dùng hoặc phân đoạn.", + "subscriptionColor": "Màu cục bộ", + "subscriptionColorHelp": "Sử dụng màu sáu chữ số như #3584E4.", + "subscriptionColorInvalid": "Nhập màu thập lục phân sáu chữ số.", + "subscriptionRefreshMode": "Tần suất làm mới", + "subscriptionAutomatic": "Tự động", + "subscriptionHourly": "Hàng giờ", + "subscriptionSixHours": "Mỗi sáu giờ", + "subscriptionDaily": "Hàng ngày", + "subscriptionSafeOrigin": "Nguồn: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Nhập URL hợp lệ để xem trước nguồn an toàn.", + "subscriptionReadOnly": "Gói đăng ký chỉ đọc", + "subscriptionNeverRefreshed": "Chưa làm mới", + "subscriptionLastRefresh": "Lần làm mới thành công gần nhất: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Lần làm mới tiếp theo: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Đã cập nhật", + "subscriptionStatusIssue": "Sự cố làm mới: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Làm mới ngay", + "unsubscribe": "Hủy đăng ký", + "unsubscribeCalendarTitle": "Hủy đăng ký “{name}”?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "Thao tác này xóa gói đăng ký cục bộ và các sự kiện đã lưu trong bộ nhớ đệm. Lịch đã xuất bản không thay đổi.", + "addSubscriptionAction": "Thêm gói đăng ký", + "subscriptionOperationFailed": "Gói đăng ký lịch không thành công: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Gói đăng ký", + "calendarImport": "Nhập lịch", + "calendarImportDescription": "Chọn tệp, xem lại các sự kiện rồi chọn lịch có thể ghi để nhận chúng.", + "importIcsFile": "Nhập tệp .ics", + "importIcsPreview": "Nhập sự kiện lịch", + "importEventsFound": "Bộ sự kiện có thể nhập: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Sự kiện không hợp lệ: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Cố ý bỏ qua: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "Không có lịch đích có thể ghi.", + "importDestinationCalendar": "Lịch đích", + "importIcsConfirm": "Nhập sự kiện", + "importIcsComplete": "Đã nhập xong", + "importQueued": "Đã nhập hoặc xếp hàng: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Đã bỏ qua bản sao: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Bộ lặp lại không được hỗ trợ: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "Không thể nhập tệp lịch: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "Ngoại tuyến", "networkOfflineDescription": "Các thay đổi sẽ được đồng bộ khi kết nối được khôi phục.", - "networkOfflineTryAgain": "Bạn đang ngoại tuyến. Hãy kết nối Internet rồi thử lại." + "networkOfflineTryAgain": "Bạn đang ngoại tuyến. Hãy kết nối Internet rồi thử lại.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "vào các ngày {days} trong tháng", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first} và {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} và {second}", + "repeatMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} và {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} và {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} vào ngày {day} {month}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} vào ngày {day} của {months}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} vào các ngày {days} của {months}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} vào {days} {position, select, first{đầu tiên} second{thứ hai} third{thứ ba} fourth{thứ tư} fifth{thứ năm} secondToLast{áp chót} last{cuối cùng} other{}} của {months}", + "repeatYearlyOnMonthDaysSummary": "{frequency} vào các ngày {days} của {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} vào {days} {position, select, first{đầu tiên} second{thứ hai} third{thứ ba} fourth{thứ tư} fifth{thứ năm} secondToLast{áp chót} last{cuối cùng} other{}} của {month}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index b7ae4f7a..fed5c131 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,14 +1,31 @@ { "@@locale": "zh", + "windowsSupport": "支持", + "windowsThirdPartyLicenses": "第三方许可证", + "windowsSearch": "搜索", + "windowsStartupDisabledByUser": "用户已在 Windows 设置中禁用。", + "windowsStartupDisabledByPolicy": "已由 Windows 策略禁用。", + "windowsStartupUnavailable": "从 MSIX 包安装 BusyMax 后可用。", + "windowsReminderExitNotice": "完全退出 BusyMax 后,提醒将停止。请让应用在后台运行以接收提醒。", + "windowsProductVersionLabel": "产品版本", + "windowsPackageVersionLabel": "Windows 包版本", + "windowsUnpackaged": "未打包", + "windowsAgendaLoadMore": "載入更多議程項目", + "repeatWeeklyDaySummary": "{dayKey, select, MO{星期一} TU{星期二} WE{星期三} TH{星期四} FR{星期五} SA{星期六} SU{星期日} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "{first}、{second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency}{month}的{firstDay}和{secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency}{firstMonth}和{secondMonth}的{day}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency}{firstMonth}和{secondMonth}的{firstDay}和{secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency}{firstMonth}和{secondMonth}的{days}", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "连接 Google、Microsoft、Apple iCloud Calendar 或 Nextcloud 账户。", "googlePermissionsConsentNotice": "在 Google 权限页面上,同时选择日历和任务权限。", "googlePermissionsRequiredRetry": "必须授予 Google 日历和 Google Tasks 权限。请重试并选中两个复选框。", "finishSetup": "完成设置", "continueSetup": "继续", "onboardingSetupTitle": "设置 BusyMax", - "onboardingAccountsStepTitle": "连接帐户", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepTitle": "连接账户", + "onboardingAccountsStepDescription": "添加所有要使用的账户。BusyMax 会同步每个账户中受支持的日历、日程、任务列表和任务。", "onboardingPreferencesStepTitle": "选择系统设置", "onboardingPreferencesStepDescription": "打开日程前,请设置桌面行为、提醒、通知详细程度和外观。", "signInWithGoogle": "使用 Google 登录", @@ -33,6 +50,43 @@ "calendarTasks": "任务", "calendar": "日历", "calendars": "日历", + "newCalendar": "新建日历", + "calendarColor": "日历颜色", + "calendarColorOption": "颜色 {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "此提供商不支持 BusyMax 中的日历管理。", + "primaryCalendarCannotDelete": "无法删除主日历。", + "calendarCreateFailed": "无法创建日历:{error}", + "calendarCreatedRefreshPending": "日历已创建,但 BusyMax 无法刷新账户。它将在下次同步后显示。", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "无法更新日历:{error}", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "无法删除日历:{error}", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "新建日程", "refreshCalendar": "刷新日历", "openInProvider": "在服务中打开", @@ -46,12 +100,61 @@ "scheduleUnavailable": "日程不可用", "scheduleNoSources": "没有可见的日历或任务列表", "scheduleNoSourcesDescription": "请在设置中选择要显示的内容,然后刷新。", - "scheduleSignInRequired": "连接帐户", + "scheduleSignInRequired": "连接账户", "scheduleSignInDescription": "登录以同步日历和任务。", "scheduleNoSearchResults": "没有匹配的日程或任务", "scheduleNoSearchResultsDescription": "请尝试其他搜索内容或清除当前筛选条件。", "refresh": "刷新", "trayOpenBusyMax": "打开 BusyMax", + "trayShowBusyMax": "显示 BusyMax", + "trayNewEvent": "新日程…", + "trayNewTask": "新任务…", + "trayToday": "今天", + "trayAllDay": "全天", + "trayNow": "现在", + "trayCalendarEvent": "日历日程", + "trayUntitledEvent": "无标题日程", + "trayNothingElseToday": "今天没有其他内容", + "trayTasksDueToday": "{count, plural, =1{今天有 1 个任务到期} other{今天有 {count} 个任务到期}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "打开今天的日程", + "traySyncNow": "立即同步", + "traySyncing": "正在同步…", + "trayNotConnected": "未连接", + "trayNotYetSynced": "尚未同步", + "trayLastSyncedJustNow": "刚刚同步", + "trayLastSyncedMinutesAgo": "{count, plural, =1{1 分钟前同步} other{{count} 分钟前同步}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{1 小时前同步} other{{count} 小时前同步}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{1 天前同步} other{{count} 天前同步}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "设置", + "trayQuitBusyMax": "退出 BusyMax", "agendaLoadMoreOverdue": "加载更多逾期任务", "agendaLoadMoreNoDate": "加载更多无日期任务", "viewDay": "日", @@ -78,7 +181,7 @@ "doesNotRepeat": "不重复", "defaultReminder": "默认提醒", "guests": "参与者", - "noGuests": "没有参与者", + "noGuests": "无参与者", "description": "说明", "availabilityShowAs": "空闲状态 / 显示为", "busy": "忙碌", @@ -154,7 +257,7 @@ "feedbackDetailedMessage": "详细信息", "feedbackReplyEmail": "用于接收回复的电子邮件地址(可选)", "feedbackIncludeTechnicalDetails": "包含技术详情", - "feedbackTechnicalDetailsDisclosure": "仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、帐户数据、文件名或其他诊断信息。", + "feedbackTechnicalDetailsDisclosure": "仅添加您的操作系统名称和版本以及应用区域设置。不包含日志、账户数据、文件名或其他诊断信息。", "feedbackCategoryRequired": "请选择类别。", "feedbackSubjectLengthError": "主题必须为 3 至 120 个字符。", "feedbackMessageLengthError": "消息必须为 10 至 5,000 个字符。", @@ -168,25 +271,25 @@ "toggleSidebar": "显示或隐藏侧边栏", "showSidebar": "显示侧边栏面板", "hideSidebar": "隐藏侧边栏面板", - "accounts": "帐户", - "currentAccount": "当前帐户", - "switchAccount": "切换帐户", - "addGoogleAccount": "添加 Google 帐户", - "addMicrosoftAccount": "添加 Microsoft 帐户", + "accounts": "账户", + "currentAccount": "当前账户", + "switchAccount": "切换账户", + "addGoogleAccount": "添加 Google 账户", + "addMicrosoftAccount": "添加 Microsoft 账户", "googleProvider": "Google", "microsoftProvider": "Microsoft", "signedInAccount": "已登录", - "removeAccount": "移除帐户…", - "removingAccount": "正在移除帐户…", - "removeAccountDescription": "停止同步并从此设备移除此帐户的数据。", + "removeAccount": "移除账户…", + "removingAccount": "正在移除账户…", + "removeAccountDescription": "停止同步并从此设备移除此账户的数据。", "removeAccountTitle": "从 BusyMax 中移除 {account}?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", - "revokeGoogleAccess": "同时撤销 BusyMax 对此 Google 帐户的访问权限", + "removeAccountConfirmation": "这会从此设备删除缓存的任务、日历、日程、提醒和待处理的离线更改。未同步的更改将会丢失。提供商中的日历、日程、任务列表和任务副本不会被删除。", + "revokeGoogleAccess": "同时撤销 BusyMax 对此 Google 账户的访问权限", "revokeGoogleAccessDescription": "重新连接之前,您需要再次授予访问权限。", - "removeAccountAction": "移除帐户", - "removeAccountFailed": "无法完成帐户移除。请重试。", - "accountRemovedGoogleRevokeFailed": "该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。", - "newTaskList": "新建任务列表", + "removeAccountAction": "移除账户", + "removeAccountFailed": "无法完成账户移除。请重试。", + "accountRemovedGoogleRevokeFailed": "该账户已从此设备移除,但无法撤销 BusyMax 对您的 Google 账户的访问权限。您可以在 Google 账户中手动撤销该权限。", + "newTaskList": "新任务列表", "signInToViewTaskLists": "登录以查看任务列表。", "noTaskListsSynced": "尚未同步任何任务列表。", "listActions": "列表操作", @@ -196,7 +299,7 @@ "deleteList": "删除列表", "builtInMicrosoftList": "内置", "builtInMicrosoftListCannotRenameDelete": "无法重命名或删除 Microsoft To Do 内置列表。", - "deleteListConfirmation": "从 Google Tasks 中删除“{title}”?", + "deleteListConfirmation": "要从 Google Tasks 中删除“{title}”吗?", "deleteEvent": "删除日程", "title": "标题", "create": "新建", @@ -205,7 +308,7 @@ "refreshList": "刷新列表", "refreshAll": "全部刷新", "listRefreshed": "列表已刷新。", - "allTasksRefreshed": "所有帐户均已刷新。", + "allTasksRefreshed": "所有账户均已刷新。", "exportedFile": "已导出到 {path}", "exportFailed": "导出失败:{error}", "refreshFailed": "刷新失败:{error}", @@ -213,7 +316,7 @@ "signInToViewTasks": "登录以查看任务。", "noTasks": "没有任务。", "noTasksYet": "还没有任务", - "noTasksYetMessage": "创建任务或刷新帐户以开始使用。", + "noTasksYetMessage": "创建任务或刷新账户以开始使用。", "noTasksInList": "此列表中没有任务。", "overdue": "已逾期", "today": "今天", @@ -271,7 +374,7 @@ "advancedSection": "高级", "addCategory": "添加类别", "list": "列表", - "microsoftMoveUnsupported": "此版本不支持在 Microsoft To Do 帐户的列表之间移动任务。", + "microsoftMoveUnsupported": "此版本不支持在 Microsoft To Do 账户的列表之间移动任务。", "createSubtask": "创建子任务", "subtasks": "子任务", "moveToTop": "移到顶部", @@ -289,9 +392,10 @@ "localState": "本地状态", "pendingSync": "等待同步", "synced": "已同步", - "account": "帐户", + "account": "账户", "sync": "同步", - "manualFullSync": "手动完整同步", + "forceFullResync": "强制完全重新同步", + "forceFullResyncDescription": "从每个已连接的账号中重新完整加载所有数据。请仅在排查同步问题时使用此功能。", "runInBackgroundWhenClosed": "窗口关闭后继续在后台运行", "showTrayIcon": "显示托盘图标", "startMinimizedToTray": "启动时最小化到托盘", @@ -305,6 +409,7 @@ "notifyConflicts": "冲突通知", "notifyDueToday": "今天到期任务通知", "eventReminders": "日程提醒", + "onState": "开启", "taskReminders": "任务提醒", "notificationDetailLevel": "通知详细程度", "notificationDetailPrivate": "私密", @@ -314,9 +419,13 @@ "quietHoursStart": "免打扰开始时间", "quietHoursEnd": "免打扰结束时间", "notifications": "通知", + "windowsNotificationsUnavailable": "Windows 通知不可用", + "windowsNotificationsUnpackaged": "此未打包的开发版本无法使用 Windows 通知。请安装测试签名的 MSIX 以测试提醒。", + "windowsNotificationsInstalledFailure": "BusyMax 无法初始化 Windows 通知。在解决此安装问题之前,提醒不会显示。", "appearance": "外观", "theme": "主题", "themeSystem": "系统", + "settingsSystem": "系统", "themeLight": "浅色", "themeDark": "深色", "themeFamily": "主题系列", @@ -361,7 +470,7 @@ "taskReminderNotificationBody": "任务即将到期。", "notificationOpenAction": "打开", "notificationSnoozeAction": "10 分钟后提醒", - "notificationDismissAction": "忽略", + "notificationDismissAction": "关闭", "notificationDetailsHidden": "根据隐私设置,详细信息已隐藏。", "previousMonth": "上个月", "nextMonth": "下个月", @@ -376,50 +485,50 @@ "selectTimeZone": "选择时区", "searchLocations": "搜索地点", "noLocationsFound": "未找到地点", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "此字段为必填项。", + "providerConnectionDescription": "连接以下任一提供商中的日历和任务。", + "appleICloudProvider": "Apple iCloud 日历", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloud 任务", + "addAppleICloudAccount": "添加 Apple iCloud 日历账户", + "addNextcloudAccount": "添加 Nextcloud 账户", + "waitingForAppleICloud": "正在连接 Apple iCloud…", + "waitingForNextcloud": "正在等待 Nextcloud 授权…", + "connectAppleICloudTitle": "连接 Apple iCloud 日历", + "appleAccountEmail": "Apple 账户电子邮件", + "appleAppSpecificPassword": "App 专用密码", + "appleAppSpecificPasswordHelp": "为 Apple 账户启用双重认证后,创建 App 专用密码。", + "appleAppSpecificPasswordResetWarning": "重置 Apple 账户密码会撤销 App 专用密码。", + "connectNextcloudTitle": "连接 Nextcloud", + "nextcloudServerUrl": "Nextcloud 服务器或 CalDAV 地址", + "nextcloudServerUrlHelp": "输入 Nextcloud 服务器 URL,或粘贴从 Nextcloud 复制的主要 CalDAV 地址。", + "nextcloudBrowserAuthorizationHelp": "BusyMax 将打开浏览器。请在那里批准访问,然后返回 BusyMax。", + "connectAccountAction": "连接", + "cancelAccountConnection": "取消连接", + "nextcloudAccountRemovedRevokeFailed": "账户已在本地移除,但无法撤销 Nextcloud App 密码。", + "davCachedOfflineNotice": "日历和任务数据会缓存在本地,以供离线使用。", + "davReauthenticationRequired": "重新连接此账户以恢复同步。", + "davTemporarilyUnavailable": "此账户暂时不可用。", + "davPermissionChanged": "服务器权限已更改。待处理的编辑已暂停。", + "davUnsupportedServer": "不支持此服务器或提供商配置。", + "collectionSettings": "日历和任务列表", + "calendarContent": "日历日程", + "taskContent": "任务", + "readOnlySharedCollection": "只读", + "pendingLocally": "本地待处理", + "conflictBlocked": "因冲突而阻止", + "authenticationBlocked": "重新连接前阻止", + "operationFailed": "操作失败", + "keepServerVersion": "保留服务器版本", + "reapplyLocalChange": "查看并重新应用本地更改", + "duplicateLocalItem": "复制为新项目", "deleteCalendarConfirmation": "删除“{title}”?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "连接状态", + "davConnected": "已连接", + "davConnecting": "正在连接…", + "davSignedOut": "已退出登录", + "davLastSuccessfulSync": "上次成功同步:{time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -427,9 +536,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "尚未同步", + "refreshCollections": "刷新日历和任务列表", + "nextcloudServerHost": "服务器:{host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -437,12 +546,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "日程日历", + "collectionSupportsTasks": "任务列表", + "collectionSupportsEventsAndTasks": "日程和任务", + "writableCollection": "可写", + "sharedCollection": "已共享", + "collectionLastSynced": "上次同步:{time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -450,7 +559,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "同步问题:{code}", "@collectionSyncError": { "placeholders": { "code": { @@ -458,8 +567,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "同步冲突", + "remoteChangedAt": "服务器更改:{time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -467,7 +576,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "本地编辑:{summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -475,144 +584,366 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "conflictResolutionFailed": "无法解决冲突。", + "recurringEventScope": "重复日程范围", + "entireSeries": "整个系列", + "singleOccurrence": "此事件", + "thisAndFollowingEvents": "此事件及后续事件", + "thisAndFutureUnavailable": "此提供商不支持。", + "thisAndFutureMoveUnavailable": "无法安全移动此日程及后续日程。请选择此日程或整个系列。", + "entireSeriesMoveUnavailable": "本地没有可用的重复规则。请仅移动此日程。", + "copyEventAndDeleteOriginal": "复制日程并删除原日程?", + "copyEventMoveWarning": "BusyMax 无法将此日程直接从 {source} 移动到 {destination}。应用会先创建副本,并且仅在复制成功后删除原日程。日程 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。", + "copyAndDelete": "复制并删除", + "chooseRecurringEventScope": "选择此更改适用于整个系列、仅此日程,还是此日程及后续日程。", "taskDueBeforeStart": "截止时间不能早于开始时间。", "taskStartDueTimeModeMismatch": "请同时设置开始和截止时间,或将任务设为全天。", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskListCreateFailed": "无法创建任务列表:{error}", + "taskListRenameFailed": "无法重命名任务列表:{error}", + "taskListDeleteFailed": "无法删除任务列表:{error}", "unshare": "取消共享", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "readOnlyTaskListCannotRename": "此任务列表为只读,无法重命名。", + "taskListCannotDelete": "使用当前权限无法删除此任务列表。", + "deleteTaskListConfirmation": "要删除“{title}”及其所有任务吗?", + "unshareTaskListConfirmation": "要取消此账户对“{title}”的共享吗?", + "taskStatus": "状态", + "taskStatusNone": "无状态", "taskStatusNeedsAction": "需要操作", - "taskStatusInProcess": "处理中", - "taskStatusCompleted": "完成", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusInProcess": "进行中", + "taskStatusCompleted": "已完成", + "taskStatusCancelled": "已取消", + "completionPercent": "已完成 {percent}%", + "completionDate": "完成日期", "priority": "优先级", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", + "priorityNone": "无优先级", + "priorityHighValue": "优先级 {priority} · 高", + "priorityMediumValue": "优先级 {priority} · 中", + "priorityLowValue": "优先级 {priority} · 低", + "taskUrl": "任务 URL", + "invalidTaskUrl": "请输入包含方案的绝对 URL。", + "classification": "分类", + "classificationPublic": "共享时显示完整任务", + "classificationConfidential": "共享时仅显示忙碌状态", + "classificationPrivate": "共享时隐藏此任务", + "pinTask": "固定任务", + "reminders": "提醒", "noReminders": "无提醒", - "editReminder": "Edit reminder", + "editReminder": "编辑提醒", "beforeTaskStarts": "任务开始前", - "beforeTaskDue": "任务截止前", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", + "beforeTaskDue": "任务到期前", + "afterTaskStarts": "任务开始后", + "afterTaskDue": "任务到期后", + "relativeToTaskStart": "相对于任务开始日期", + "relativeToTaskDue": "相对于任务到期日期", + "reminderTimeOfDay": "时间", + "absoluteReminder": "在指定日期和时间", + "reminderAmount": "数量", + "reminderUnitSeconds": "秒", + "reminderUnitMinutes": "分钟", + "reminderUnitHours": "小时", + "reminderUnitDays": "天", + "reminderUnitWeeks": "周", + "reminderAtTaskStart": "任务开始时", + "reminderAtTaskDue": "任务到期时", + "unsupportedReminder": "此提醒类型会保留,但无法编辑其时间。", + "relatedRemindersTitle": "保留相关提醒?", + "relatedRemindersDescription": "此日期有 {count} 个相关提醒。要保留它们当前的日期和时间吗?", "discardRelatedReminders": "舍弃提醒", "keepRelatedReminders": "保留提醒", - "repeatEvery": "重复每", - "repeatOn": "Repeat on", + "repeatEvery": "重复间隔", + "repeatOn": "重复日期", "repeatEnd": "结束重复", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", + "repeatNever": "从不", + "repeatUntil": "指定日期", + "repeatAfter": "指定次数后", + "repeatCount": "重复次数", + "repeatDayOfMonth": "每月日期", + "repeatMonths": "月份", + "repeatOrdinal": "星期位置", + "repeatSpecificDays": "特定日期", + "repeatFirst": "第一", + "repeatSecond": "第二", + "repeatThird": "第三", + "repeatFourth": "第四", + "repeatFifth": "第五", + "repeatSecondToLast": "倒数第二", + "repeatLast": "最后", + "repeatAnyDay": "日期", + "repeatWeekday": "工作日", + "repeatWeekendDay": "周末", + "repeatOrdinalDaySummary": "{dayKey, select, MO{星期一} TU{星期二} WE{星期三} TH{星期四} FR{星期五} SA{星期六} SU{星期日} day{日期} weekday{工作日} weekend{周末} other{{day}}}", + "repeatEveryDays": "每 {count} 天", + "repeatEveryWeeks": "每 {count} 周", + "repeatEveryMonths": "每 {count} 个月", + "repeatEveryYears": "每 {count} 年", "repeatOnDaysSummary": "在 {days}", - "repeatOnMonthDaysSummary": "在第 {days} 天", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", + "repeatOnMonthDaysSummary": "{days}", + "repeatOnOrdinalSummary": "{position, select, first{第一个{days}} second{第二个{days}} third{第三个{days}} fourth{第四个{days}} fifth{第五个{days}} secondToLast{倒数第二个{days}} last{最后一个{days}} other{{days}}}", "repeatInMonthsSummary": "在 {months}", - "repeatTimesSummary": "{count}次", - "repeatUntilSummary": "至 {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "repeatTimesSummary": "{count, plural, other{重复 {count} 次}}", + "repeatUntilSummary": "直到 {date}", + "unsupportedRecurrencePreserved": "此重复规则使用了编辑器不会更改的选项。", + "recurrenceUnsupportedByProvider": "此重复规则不能用于 {provider}。", "duplicateTask": "复制任务", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "taskDuplicated": "任务已复制。", + "taskDuplicateFailed": "无法复制任务:{error}", "hideSubtasks": "隐藏子任务", - "hideClosedSubtasks": "隐藏关闭的子任务", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "hideClosedSubtasks": "隐藏已关闭的子任务", + "reminderUnit": "单位", + "attendeeRequired": "必需", + "attendeeOptional": "可选", + "meetingSection": "会议", + "addGoogleMeet": "添加 Google Meet", + "addTeamsMeeting": "添加 Microsoft Teams 会议", + "onlineMeetingAdded": "已添加在线会议", + "requestResponses": "请求回复", + "requestResponsesDescription": "要求参与者回复邀请。", + "hideGuestList": "隐藏参与者列表", + "hideGuestListDescription": "参与者无法查看其他受邀者。", + "allowNewTimeProposals": "允许提出新时间", + "allowNewTimeProposalsDescription": "参与者可以建议其他会议时间。", + "notifyGuestsTitle": "通知参与者?", + "notifyGuestsSaveMessage": "此会议有参与者。保存时发送邀请或日程更新吗?", + "notifyGuestsDeleteMessage": "此会议有参与者。删除时发送取消通知吗?", + "sendUpdates": "发送更新", + "sendCancellation": "发送取消通知", + "doNotSend": "不发送", + "microsoftNotifyGuestsSaveTitle": "保存会议?", + "microsoftNotifyGuestsSaveMessage": "Microsoft 将向参与者发送邀请或日程更新。", + "microsoftNotifyGuestsDeleteTitle": "删除会议?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft 将向参与者发送取消通知。", + "organizer": "组织者", + "yourResponse": "你的回复", + "guestResponses": "参与者回复", + "respond": "回复", + "acceptInvitation": "接受", + "tentativeInvitation": "暂定", + "declineInvitation": "拒绝", + "joinMeeting": "加入会议", + "responseAccepted": "已接受", + "responseTentative": "暂定", + "responseDeclined": "已拒绝", + "responseNeedsAction": "等待回复", + "responseNotResponded": "未回复", + "responseOrganizer": "组织者", + "invitationResponseFailed": "无法发送回复:{error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "无法打开会议链接。", + "setCustomCalendarName": "设置自定义名称", + "setAction": "设置", + "removeFromMyCalendars": "从“我的日历”中移除", + "removeAction": "移除", + "removeCalendarConfirmation": "要从您的 Google 日历列表中移除“{title}”吗?共享日历及其日程不会被删除。", + "calendarCannotRemove": "无法从此账户删除或移除此日历。", + "calendarPendingChangesPreventRemoval": "请等待此日历的待处理更改完成同步,然后再删除或移除它。", + "calendarSubscriptions": "日历订阅", + "calendarSubscriptionsDescription": "添加从安全 WebCal URL 刷新的只读日历。", + "addCalendarSubscription": "添加日历订阅", + "subscriptionName": "本地名称", + "subscriptionUrl": "订阅 URL", + "subscriptionUrlHelp": "输入 HTTPS 或 webcal URL。BusyMax 会将完整 URL 保存在安全存储中。", + "subscriptionUrlInvalid": "请输入不含用户信息或片段的有效 HTTPS 或 webcal URL。", + "subscriptionColor": "本地颜色", + "subscriptionColorHelp": "使用六位颜色,例如 #3584E4。", + "subscriptionColorInvalid": "请输入六位十六进制颜色。", + "subscriptionRefreshMode": "刷新频率", + "subscriptionAutomatic": "自动", + "subscriptionHourly": "每小时", + "subscriptionSixHours": "每六小时", + "subscriptionDaily": "每天", + "subscriptionSafeOrigin": "来源:{origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "请输入有效 URL 以预览其安全来源。", + "subscriptionReadOnly": "只读订阅", + "subscriptionNeverRefreshed": "尚未刷新", + "subscriptionLastRefresh": "上次成功刷新:{time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "下次刷新:{time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "已是最新", + "subscriptionStatusIssue": "刷新问题:{code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "立即刷新", + "unsubscribe": "取消订阅", + "unsubscribeCalendarTitle": "要取消订阅“{name}”吗?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "这会移除本地订阅及其缓存的日程。已发布的日历不会更改。", + "addSubscriptionAction": "添加订阅", + "subscriptionOperationFailed": "日历订阅失败:{error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "订阅", + "calendarImport": "日历导入", + "calendarImportDescription": "选择文件,查看其中的日程,然后选择接收这些日程的可写日历。", + "importIcsFile": "导入 .ics 文件", + "importIcsPreview": "导入日历日程", + "importEventsFound": "可导入日程集:{count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "无效日程:{count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "有意省略:{fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "没有可用的可写目标日历。", + "importDestinationCalendar": "目标日历", + "importIcsConfirm": "导入日程", + "importIcsComplete": "导入完成", + "importQueued": "已导入或已排队:{count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "已跳过重复项:{count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "不支持的重复集:{count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "无法导入日历文件:{error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "离线", "networkOfflineDescription": "恢复连接后将同步更改。", - "networkOfflineTryAgain": "您当前处于离线状态。请连接互联网后重试。" + "networkOfflineTryAgain": "您当前处于离线状态。请连接互联网后重试。", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "{days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": "", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}日", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first}和{second}", + "repeatWeekdayListStart": "{first}、{rest}", + "repeatMonthDayListPair": "{first}、{second}", + "repeatMonthDayListStart": "{first}、{rest}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first}和{second}", + "repeatYearlyMonthDayListStart": "{first}、{rest}", + "repeatYearlyMonthListPair": "{first}和{second}", + "repeatYearlyMonthListStart": "{first}、{rest}", + "repeatYearlyOnMonthDaySummary": "{frequency}{month}{day}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency}{months}的{day}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency}{months}的{days}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency}{months}的{position, select, first{第一个{days}} second{第二个{days}} third{第三个{days}} fourth{第四个{days}} fifth{第五个{days}} secondToLast{倒数第二个{days}} last{最后一个{days}} other{{days}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency}{month}的{days}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency}{month}的{position, select, first{第一个{days}} second{第二个{days}} third{第三个{days}} fourth{第四个{days}} fifth{第五个{days}} secondToLast{倒数第二个{days}} last{最后一个{days}} other{{days}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index c74bfd03..58f64fdf 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1,14 +1,31 @@ { "@@locale": "zh_Hans", + "windowsSupport": "支持", + "windowsThirdPartyLicenses": "第三方许可证", + "windowsSearch": "搜索", + "windowsStartupDisabledByUser": "用户已在 Windows 设置中禁用。", + "windowsStartupDisabledByPolicy": "已由 Windows 策略禁用。", + "windowsStartupUnavailable": "从 MSIX 包安装 BusyMax 后可用。", + "windowsReminderExitNotice": "完全退出 BusyMax 后,提醒将停止。请让应用在后台运行以接收提醒。", + "windowsProductVersionLabel": "产品版本", + "windowsPackageVersionLabel": "Windows 包版本", + "windowsUnpackaged": "未打包", + "windowsAgendaLoadMore": "加载更多议程项目", + "repeatWeeklyDaySummary": "{dayKey, select, MO{星期一} TU{星期二} WE{星期三} TH{星期四} FR{星期五} SA{星期六} SU{星期日} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "{first}、{second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency}{month}的{firstDay}和{secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency}{firstMonth}和{secondMonth}的{day}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency}{firstMonth}和{secondMonth}的{firstDay}和{secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency}{firstMonth}和{secondMonth}的{days}", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "连接 Google、Microsoft、Apple iCloud Calendar 或 Nextcloud 账户。", "googlePermissionsConsentNotice": "在 Google 权限页面上,同时选择日历和任务权限。", "googlePermissionsRequiredRetry": "必须授予 Google 日历和 Google Tasks 权限。请重试并选中两个复选框。", "finishSetup": "完成设置", "continueSetup": "继续", "onboardingSetupTitle": "设置 BusyMax", - "onboardingAccountsStepTitle": "连接帐户", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepTitle": "连接账户", + "onboardingAccountsStepDescription": "添加所有要使用的账户。BusyMax 会同步每个账户中受支持的日历、日程、任务列表和任务。", "onboardingPreferencesStepTitle": "选择系统设置", "onboardingPreferencesStepDescription": "打开日程前,请设置桌面行为、提醒、通知详细程度和外观。", "signInWithGoogle": "使用 Google 登录", @@ -33,6 +50,43 @@ "calendarTasks": "任务", "calendar": "日历", "calendars": "日历", + "newCalendar": "新建日历", + "calendarColor": "日历颜色", + "calendarColorOption": "颜色 {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "此提供商不支持 BusyMax 中的日历管理。", + "primaryCalendarCannotDelete": "无法删除主日历。", + "calendarCreateFailed": "无法创建日历:{error}", + "calendarCreatedRefreshPending": "日历已创建,但 BusyMax 无法刷新账户。它将在下次同步后显示。", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "无法更新日历:{error}", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "无法删除日历:{error}", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "新建日程", "refreshCalendar": "刷新日历", "openInProvider": "在服务中打开", @@ -46,12 +100,61 @@ "scheduleUnavailable": "日程不可用", "scheduleNoSources": "没有可见的日历或任务列表", "scheduleNoSourcesDescription": "请在设置中选择要显示的内容,然后刷新。", - "scheduleSignInRequired": "连接帐户", + "scheduleSignInRequired": "连接账户", "scheduleSignInDescription": "登录以同步日历和任务。", "scheduleNoSearchResults": "没有匹配的日程或任务", "scheduleNoSearchResultsDescription": "请尝试其他搜索内容或清除当前筛选条件。", "refresh": "刷新", "trayOpenBusyMax": "打开 BusyMax", + "trayShowBusyMax": "显示 BusyMax", + "trayNewEvent": "新日程…", + "trayNewTask": "新任务…", + "trayToday": "今天", + "trayAllDay": "全天", + "trayNow": "现在", + "trayCalendarEvent": "日历日程", + "trayUntitledEvent": "无标题日程", + "trayNothingElseToday": "今天没有其他内容", + "trayTasksDueToday": "{count, plural, =1{今天有 1 个任务到期} other{今天有 {count} 个任务到期}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "打开今天的日程", + "traySyncNow": "立即同步", + "traySyncing": "正在同步…", + "trayNotConnected": "未连接", + "trayNotYetSynced": "尚未同步", + "trayLastSyncedJustNow": "刚刚同步", + "trayLastSyncedMinutesAgo": "{count, plural, =1{1 分钟前同步} other{{count} 分钟前同步}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{1 小时前同步} other{{count} 小时前同步}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{1 天前同步} other{{count} 天前同步}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "设置", + "trayQuitBusyMax": "退出 BusyMax", "agendaLoadMoreOverdue": "加载更多逾期任务", "agendaLoadMoreNoDate": "加载更多无日期任务", "viewDay": "日", @@ -78,7 +181,7 @@ "doesNotRepeat": "不重复", "defaultReminder": "默认提醒", "guests": "参与者", - "noGuests": "没有参与者", + "noGuests": "无参与者", "description": "说明", "availabilityShowAs": "空闲状态 / 显示为", "busy": "忙碌", @@ -154,7 +257,7 @@ "feedbackDetailedMessage": "详细信息", "feedbackReplyEmail": "用于接收回复的电子邮件地址(可选)", "feedbackIncludeTechnicalDetails": "包含技术详情", - "feedbackTechnicalDetailsDisclosure": "仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、帐户数据、文件名或其他诊断信息。", + "feedbackTechnicalDetailsDisclosure": "仅添加您的操作系统名称和版本以及应用区域设置。不包含日志、账户数据、文件名或其他诊断信息。", "feedbackCategoryRequired": "请选择类别。", "feedbackSubjectLengthError": "主题必须为 3 至 120 个字符。", "feedbackMessageLengthError": "消息必须为 10 至 5,000 个字符。", @@ -168,25 +271,25 @@ "toggleSidebar": "显示或隐藏侧边栏", "showSidebar": "显示侧边栏面板", "hideSidebar": "隐藏侧边栏面板", - "accounts": "帐户", - "currentAccount": "当前帐户", - "switchAccount": "切换帐户", - "addGoogleAccount": "添加 Google 帐户", - "addMicrosoftAccount": "添加 Microsoft 帐户", + "accounts": "账户", + "currentAccount": "当前账户", + "switchAccount": "切换账户", + "addGoogleAccount": "添加 Google 账户", + "addMicrosoftAccount": "添加 Microsoft 账户", "googleProvider": "Google", "microsoftProvider": "Microsoft", "signedInAccount": "已登录", - "removeAccount": "移除帐户…", - "removingAccount": "正在移除帐户…", - "removeAccountDescription": "停止同步并从此设备移除此帐户的数据。", + "removeAccount": "移除账户…", + "removingAccount": "正在移除账户…", + "removeAccountDescription": "停止同步并从此设备移除此账户的数据。", "removeAccountTitle": "从 BusyMax 中移除 {account}?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", - "revokeGoogleAccess": "同时撤销 BusyMax 对此 Google 帐户的访问权限", + "removeAccountConfirmation": "这会从此设备删除缓存的任务、日历、日程、提醒和待处理的离线更改。未同步的更改将会丢失。提供商中的日历、日程、任务列表和任务副本不会被删除。", + "revokeGoogleAccess": "同时撤销 BusyMax 对此 Google 账户的访问权限", "revokeGoogleAccessDescription": "重新连接之前,您需要再次授予访问权限。", - "removeAccountAction": "移除帐户", - "removeAccountFailed": "无法完成帐户移除。请重试。", - "accountRemovedGoogleRevokeFailed": "该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。", - "newTaskList": "新建任务列表", + "removeAccountAction": "移除账户", + "removeAccountFailed": "无法完成账户移除。请重试。", + "accountRemovedGoogleRevokeFailed": "该账户已从此设备移除,但无法撤销 BusyMax 对您的 Google 账户的访问权限。您可以在 Google 账户中手动撤销该权限。", + "newTaskList": "新任务列表", "signInToViewTaskLists": "登录以查看任务列表。", "noTaskListsSynced": "尚未同步任何任务列表。", "listActions": "列表操作", @@ -196,7 +299,7 @@ "deleteList": "删除列表", "builtInMicrosoftList": "内置", "builtInMicrosoftListCannotRenameDelete": "无法重命名或删除 Microsoft To Do 内置列表。", - "deleteListConfirmation": "从 Google Tasks 中删除“{title}”?", + "deleteListConfirmation": "要从 Google Tasks 中删除“{title}”吗?", "deleteEvent": "删除日程", "title": "标题", "create": "新建", @@ -205,7 +308,7 @@ "refreshList": "刷新列表", "refreshAll": "全部刷新", "listRefreshed": "列表已刷新。", - "allTasksRefreshed": "所有帐户均已刷新。", + "allTasksRefreshed": "所有账户均已刷新。", "exportedFile": "已导出到 {path}", "exportFailed": "导出失败:{error}", "refreshFailed": "刷新失败:{error}", @@ -213,7 +316,7 @@ "signInToViewTasks": "登录以查看任务。", "noTasks": "没有任务。", "noTasksYet": "还没有任务", - "noTasksYetMessage": "创建任务或刷新帐户以开始使用。", + "noTasksYetMessage": "创建任务或刷新账户以开始使用。", "noTasksInList": "此列表中没有任务。", "overdue": "已逾期", "today": "今天", @@ -271,7 +374,7 @@ "advancedSection": "高级", "addCategory": "添加类别", "list": "列表", - "microsoftMoveUnsupported": "此版本不支持在 Microsoft To Do 帐户的列表之间移动任务。", + "microsoftMoveUnsupported": "此版本不支持在 Microsoft To Do 账户的列表之间移动任务。", "createSubtask": "创建子任务", "subtasks": "子任务", "moveToTop": "移到顶部", @@ -289,9 +392,10 @@ "localState": "本地状态", "pendingSync": "等待同步", "synced": "已同步", - "account": "帐户", + "account": "账户", "sync": "同步", - "manualFullSync": "手动完整同步", + "forceFullResync": "强制完全重新同步", + "forceFullResyncDescription": "从每个已连接的账号中重新完整加载所有数据。请仅在排查同步问题时使用此功能。", "runInBackgroundWhenClosed": "窗口关闭后继续在后台运行", "showTrayIcon": "显示托盘图标", "startMinimizedToTray": "启动时最小化到托盘", @@ -305,6 +409,7 @@ "notifyConflicts": "冲突通知", "notifyDueToday": "今天到期任务通知", "eventReminders": "日程提醒", + "onState": "开启", "taskReminders": "任务提醒", "notificationDetailLevel": "通知详细程度", "notificationDetailPrivate": "私密", @@ -314,9 +419,13 @@ "quietHoursStart": "免打扰开始时间", "quietHoursEnd": "免打扰结束时间", "notifications": "通知", + "windowsNotificationsUnavailable": "Windows 通知不可用", + "windowsNotificationsUnpackaged": "此未打包的开发版本无法使用 Windows 通知。请安装测试签名的 MSIX 以测试提醒。", + "windowsNotificationsInstalledFailure": "BusyMax 无法初始化 Windows 通知。在解决此安装问题之前,提醒不会显示。", "appearance": "外观", "theme": "主题", "themeSystem": "系统", + "settingsSystem": "系统", "themeLight": "浅色", "themeDark": "深色", "themeFamily": "主题系列", @@ -361,7 +470,7 @@ "taskReminderNotificationBody": "任务即将到期。", "notificationOpenAction": "打开", "notificationSnoozeAction": "10 分钟后提醒", - "notificationDismissAction": "忽略", + "notificationDismissAction": "关闭", "notificationDetailsHidden": "根据隐私设置,详细信息已隐藏。", "previousMonth": "上个月", "nextMonth": "下个月", @@ -376,50 +485,50 @@ "selectTimeZone": "选择时区", "searchLocations": "搜索地点", "noLocationsFound": "未找到地点", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "此字段为必填项。", + "providerConnectionDescription": "连接以下任一提供商中的日历和任务。", + "appleICloudProvider": "Apple iCloud 日历", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloud 任务", + "addAppleICloudAccount": "添加 Apple iCloud 日历账户", + "addNextcloudAccount": "添加 Nextcloud 账户", + "waitingForAppleICloud": "正在连接 Apple iCloud…", + "waitingForNextcloud": "正在等待 Nextcloud 授权…", + "connectAppleICloudTitle": "连接 Apple iCloud 日历", + "appleAccountEmail": "Apple 账户电子邮件", + "appleAppSpecificPassword": "App 专用密码", + "appleAppSpecificPasswordHelp": "为 Apple 账户启用双重认证后,创建 App 专用密码。", + "appleAppSpecificPasswordResetWarning": "重置 Apple 账户密码会撤销 App 专用密码。", + "connectNextcloudTitle": "连接 Nextcloud", + "nextcloudServerUrl": "Nextcloud 服务器或 CalDAV 地址", + "nextcloudServerUrlHelp": "输入 Nextcloud 服务器 URL,或粘贴从 Nextcloud 复制的主要 CalDAV 地址。", + "nextcloudBrowserAuthorizationHelp": "BusyMax 将打开浏览器。请在那里批准访问,然后返回 BusyMax。", + "connectAccountAction": "连接", + "cancelAccountConnection": "取消连接", + "nextcloudAccountRemovedRevokeFailed": "账户已在本地移除,但无法撤销 Nextcloud App 密码。", + "davCachedOfflineNotice": "日历和任务数据会缓存在本地,以供离线使用。", + "davReauthenticationRequired": "重新连接此账户以恢复同步。", + "davTemporarilyUnavailable": "此账户暂时不可用。", + "davPermissionChanged": "服务器权限已更改。待处理的编辑已暂停。", + "davUnsupportedServer": "不支持此服务器或提供商配置。", + "collectionSettings": "日历和任务列表", + "calendarContent": "日历日程", + "taskContent": "任务", + "readOnlySharedCollection": "只读", + "pendingLocally": "本地待处理", + "conflictBlocked": "因冲突而阻止", + "authenticationBlocked": "重新连接前阻止", + "operationFailed": "操作失败", + "keepServerVersion": "保留服务器版本", + "reapplyLocalChange": "查看并重新应用本地更改", + "duplicateLocalItem": "复制为新项目", "deleteCalendarConfirmation": "删除“{title}”?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "连接状态", + "davConnected": "已连接", + "davConnecting": "正在连接…", + "davSignedOut": "已退出登录", + "davLastSuccessfulSync": "上次成功同步:{time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -427,9 +536,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "尚未同步", + "refreshCollections": "刷新日历和任务列表", + "nextcloudServerHost": "服务器:{host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -437,12 +546,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "日程日历", + "collectionSupportsTasks": "任务列表", + "collectionSupportsEventsAndTasks": "日程和任务", + "writableCollection": "可写", + "sharedCollection": "已共享", + "collectionLastSynced": "上次同步:{time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -450,7 +559,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "同步问题:{code}", "@collectionSyncError": { "placeholders": { "code": { @@ -458,8 +567,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "同步冲突", + "remoteChangedAt": "服务器更改:{time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -467,7 +576,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "本地编辑:{summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -475,144 +584,366 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "conflictResolutionFailed": "无法解决冲突。", + "recurringEventScope": "重复日程范围", + "entireSeries": "整个系列", + "singleOccurrence": "此事件", + "thisAndFollowingEvents": "此事件及后续事件", + "thisAndFutureUnavailable": "此提供商不支持。", + "thisAndFutureMoveUnavailable": "无法安全移动此日程及后续日程。请选择此日程或整个系列。", + "entireSeriesMoveUnavailable": "本地没有可用的重复规则。请仅移动此日程。", + "copyEventAndDeleteOriginal": "复制日程并删除原日程?", + "copyEventMoveWarning": "BusyMax 无法将此日程直接从 {source} 移动到 {destination}。应用会先创建副本,并且仅在复制成功后删除原日程。日程 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。", + "copyAndDelete": "复制并删除", + "chooseRecurringEventScope": "选择此更改适用于整个系列、仅此日程,还是此日程及后续日程。", "taskDueBeforeStart": "截止时间不能早于开始时间。", "taskStartDueTimeModeMismatch": "请同时设置开始和截止时间,或将任务设为全天。", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskListCreateFailed": "无法创建任务列表:{error}", + "taskListRenameFailed": "无法重命名任务列表:{error}", + "taskListDeleteFailed": "无法删除任务列表:{error}", "unshare": "取消共享", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "readOnlyTaskListCannotRename": "此任务列表为只读,无法重命名。", + "taskListCannotDelete": "使用当前权限无法删除此任务列表。", + "deleteTaskListConfirmation": "要删除“{title}”及其所有任务吗?", + "unshareTaskListConfirmation": "要取消此账户对“{title}”的共享吗?", + "taskStatus": "状态", + "taskStatusNone": "无状态", "taskStatusNeedsAction": "需要操作", - "taskStatusInProcess": "处理中", - "taskStatusCompleted": "完成", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusInProcess": "进行中", + "taskStatusCompleted": "已完成", + "taskStatusCancelled": "已取消", + "completionPercent": "已完成 {percent}%", + "completionDate": "完成日期", "priority": "优先级", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", + "priorityNone": "无优先级", + "priorityHighValue": "优先级 {priority} · 高", + "priorityMediumValue": "优先级 {priority} · 中", + "priorityLowValue": "优先级 {priority} · 低", + "taskUrl": "任务 URL", + "invalidTaskUrl": "请输入包含方案的绝对 URL。", + "classification": "分类", + "classificationPublic": "共享时显示完整任务", + "classificationConfidential": "共享时仅显示忙碌状态", + "classificationPrivate": "共享时隐藏此任务", + "pinTask": "固定任务", + "reminders": "提醒", "noReminders": "无提醒", - "editReminder": "Edit reminder", + "editReminder": "编辑提醒", "beforeTaskStarts": "任务开始前", - "beforeTaskDue": "任务截止前", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", + "beforeTaskDue": "任务到期前", + "afterTaskStarts": "任务开始后", + "afterTaskDue": "任务到期后", + "relativeToTaskStart": "相对于任务开始日期", + "relativeToTaskDue": "相对于任务到期日期", + "reminderTimeOfDay": "时间", + "absoluteReminder": "在指定日期和时间", + "reminderAmount": "数量", + "reminderUnitSeconds": "秒", + "reminderUnitMinutes": "分钟", + "reminderUnitHours": "小时", + "reminderUnitDays": "天", + "reminderUnitWeeks": "周", + "reminderAtTaskStart": "任务开始时", + "reminderAtTaskDue": "任务到期时", + "unsupportedReminder": "此提醒类型会保留,但无法编辑其时间。", + "relatedRemindersTitle": "保留相关提醒?", + "relatedRemindersDescription": "此日期有 {count} 个相关提醒。要保留它们当前的日期和时间吗?", "discardRelatedReminders": "舍弃提醒", "keepRelatedReminders": "保留提醒", - "repeatEvery": "重复每", - "repeatOn": "Repeat on", + "repeatEvery": "重复间隔", + "repeatOn": "重复日期", "repeatEnd": "结束重复", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", + "repeatNever": "从不", + "repeatUntil": "指定日期", + "repeatAfter": "指定次数后", + "repeatCount": "重复次数", + "repeatDayOfMonth": "每月日期", + "repeatMonths": "月份", + "repeatOrdinal": "星期位置", + "repeatSpecificDays": "特定日期", + "repeatFirst": "第一", + "repeatSecond": "第二", + "repeatThird": "第三", + "repeatFourth": "第四", + "repeatFifth": "第五", + "repeatSecondToLast": "倒数第二", + "repeatLast": "最后", + "repeatAnyDay": "日期", + "repeatWeekday": "工作日", + "repeatWeekendDay": "周末", + "repeatOrdinalDaySummary": "{dayKey, select, MO{星期一} TU{星期二} WE{星期三} TH{星期四} FR{星期五} SA{星期六} SU{星期日} day{日期} weekday{工作日} weekend{周末} other{{day}}}", + "repeatEveryDays": "每 {count} 天", + "repeatEveryWeeks": "每 {count} 周", + "repeatEveryMonths": "每 {count} 个月", + "repeatEveryYears": "每 {count} 年", "repeatOnDaysSummary": "在 {days}", - "repeatOnMonthDaysSummary": "在第 {days} 天", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", + "repeatOnMonthDaysSummary": "{days}", + "repeatOnOrdinalSummary": "{position, select, first{第一个{days}} second{第二个{days}} third{第三个{days}} fourth{第四个{days}} fifth{第五个{days}} secondToLast{倒数第二个{days}} last{最后一个{days}} other{{days}}}", "repeatInMonthsSummary": "在 {months}", - "repeatTimesSummary": "{count}次", - "repeatUntilSummary": "至 {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "repeatTimesSummary": "{count, plural, other{重复 {count} 次}}", + "repeatUntilSummary": "直到 {date}", + "unsupportedRecurrencePreserved": "此重复规则使用了编辑器不会更改的选项。", + "recurrenceUnsupportedByProvider": "此重复规则不能用于 {provider}。", "duplicateTask": "复制任务", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "taskDuplicated": "任务已复制。", + "taskDuplicateFailed": "无法复制任务:{error}", "hideSubtasks": "隐藏子任务", - "hideClosedSubtasks": "隐藏关闭的子任务", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "hideClosedSubtasks": "隐藏已关闭的子任务", + "reminderUnit": "单位", + "attendeeRequired": "必需", + "attendeeOptional": "可选", + "meetingSection": "会议", + "addGoogleMeet": "添加 Google Meet", + "addTeamsMeeting": "添加 Microsoft Teams 会议", + "onlineMeetingAdded": "已添加在线会议", + "requestResponses": "请求回复", + "requestResponsesDescription": "要求参与者回复邀请。", + "hideGuestList": "隐藏参与者列表", + "hideGuestListDescription": "参与者无法查看其他受邀者。", + "allowNewTimeProposals": "允许提出新时间", + "allowNewTimeProposalsDescription": "参与者可以建议其他会议时间。", + "notifyGuestsTitle": "通知参与者?", + "notifyGuestsSaveMessage": "此会议有参与者。保存时发送邀请或日程更新吗?", + "notifyGuestsDeleteMessage": "此会议有参与者。删除时发送取消通知吗?", + "sendUpdates": "发送更新", + "sendCancellation": "发送取消通知", + "doNotSend": "不发送", + "microsoftNotifyGuestsSaveTitle": "保存会议?", + "microsoftNotifyGuestsSaveMessage": "Microsoft 将向参与者发送邀请或日程更新。", + "microsoftNotifyGuestsDeleteTitle": "删除会议?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft 将向参与者发送取消通知。", + "organizer": "组织者", + "yourResponse": "你的回复", + "guestResponses": "参与者回复", + "respond": "回复", + "acceptInvitation": "接受", + "tentativeInvitation": "暂定", + "declineInvitation": "拒绝", + "joinMeeting": "加入会议", + "responseAccepted": "已接受", + "responseTentative": "暂定", + "responseDeclined": "已拒绝", + "responseNeedsAction": "等待回复", + "responseNotResponded": "未回复", + "responseOrganizer": "组织者", + "invitationResponseFailed": "无法发送回复:{error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "无法打开会议链接。", + "setCustomCalendarName": "设置自定义名称", + "setAction": "设置", + "removeFromMyCalendars": "从“我的日历”中移除", + "removeAction": "移除", + "removeCalendarConfirmation": "要从您的 Google 日历列表中移除“{title}”吗?共享日历及其日程不会被删除。", + "calendarCannotRemove": "无法从此账户删除或移除此日历。", + "calendarPendingChangesPreventRemoval": "请等待此日历的待处理更改完成同步,然后再删除或移除它。", + "calendarSubscriptions": "日历订阅", + "calendarSubscriptionsDescription": "添加从安全 WebCal URL 刷新的只读日历。", + "addCalendarSubscription": "添加日历订阅", + "subscriptionName": "本地名称", + "subscriptionUrl": "订阅 URL", + "subscriptionUrlHelp": "输入 HTTPS 或 webcal URL。BusyMax 会将完整 URL 保存在安全存储中。", + "subscriptionUrlInvalid": "请输入不含用户信息或片段的有效 HTTPS 或 webcal URL。", + "subscriptionColor": "本地颜色", + "subscriptionColorHelp": "使用六位颜色,例如 #3584E4。", + "subscriptionColorInvalid": "请输入六位十六进制颜色。", + "subscriptionRefreshMode": "刷新频率", + "subscriptionAutomatic": "自动", + "subscriptionHourly": "每小时", + "subscriptionSixHours": "每六小时", + "subscriptionDaily": "每天", + "subscriptionSafeOrigin": "来源:{origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "请输入有效 URL 以预览其安全来源。", + "subscriptionReadOnly": "只读订阅", + "subscriptionNeverRefreshed": "尚未刷新", + "subscriptionLastRefresh": "上次成功刷新:{time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "下次刷新:{time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "已是最新", + "subscriptionStatusIssue": "刷新问题:{code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "立即刷新", + "unsubscribe": "取消订阅", + "unsubscribeCalendarTitle": "要取消订阅“{name}”吗?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "这会移除本地订阅及其缓存的日程。已发布的日历不会更改。", + "addSubscriptionAction": "添加订阅", + "subscriptionOperationFailed": "日历订阅失败:{error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "订阅", + "calendarImport": "日历导入", + "calendarImportDescription": "选择文件,查看其中的日程,然后选择接收这些日程的可写日历。", + "importIcsFile": "导入 .ics 文件", + "importIcsPreview": "导入日历日程", + "importEventsFound": "可导入日程集:{count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "无效日程:{count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "有意省略:{fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "没有可用的可写目标日历。", + "importDestinationCalendar": "目标日历", + "importIcsConfirm": "导入日程", + "importIcsComplete": "导入完成", + "importQueued": "已导入或已排队:{count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "已跳过重复项:{count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "不支持的重复集:{count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "无法导入日历文件:{error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "离线", "networkOfflineDescription": "恢复连接后将同步更改。", - "networkOfflineTryAgain": "您当前处于离线状态。请连接互联网后重试。" + "networkOfflineTryAgain": "您当前处于离线状态。请连接互联网后重试。", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "{days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": "", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}日", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first}和{second}", + "repeatWeekdayListStart": "{first}、{rest}", + "repeatMonthDayListPair": "{first}、{second}", + "repeatMonthDayListStart": "{first}、{rest}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first}和{second}", + "repeatYearlyMonthDayListStart": "{first}、{rest}", + "repeatYearlyMonthListPair": "{first}和{second}", + "repeatYearlyMonthListStart": "{first}、{rest}", + "repeatYearlyOnMonthDaySummary": "{frequency}{month}{day}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency}{months}的{day}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency}{months}的{days}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency}{months}的{position, select, first{第一个{days}} second{第二个{days}} third{第三个{days}} fourth{第四个{days}} fifth{第五个{days}} secondToLast{倒数第二个{days}} last{最后一个{days}} other{{days}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency}{month}的{days}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency}{month}的{position, select, first{第一个{days}} second{第二个{days}} third{第三个{days}} fourth{第四个{days}} fifth{第五个{days}} secondToLast{倒数第二个{days}} last{最后一个{days}} other{{days}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 2fdca441..55f039de 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1,14 +1,31 @@ { "@@locale": "zh_Hant", + "windowsSupport": "支援", + "windowsThirdPartyLicenses": "第三方授權", + "windowsSearch": "搜尋", + "windowsStartupDisabledByUser": "使用者已在 Windows 設定中停用。", + "windowsStartupDisabledByPolicy": "已由 Windows 原則停用。", + "windowsStartupUnavailable": "從 MSIX 套件安裝 BusyMax 後即可使用。", + "windowsReminderExitNotice": "完全結束 BusyMax 後,提醒會停止。請讓應用程式在背景執行以接收提醒。", + "windowsProductVersionLabel": "產品版本", + "windowsPackageVersionLabel": "Windows 套件版本", + "windowsUnpackaged": "未封裝", + "windowsAgendaLoadMore": "載入更多議程項目", + "repeatWeeklyDaySummary": "{dayKey, select, MO{星期一} TU{星期二} WE{星期三} TH{星期四} FR{星期五} SA{星期六} SU{星期日} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "{first}、{second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency}{month}的{firstDay}和{secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency}{firstMonth}和{secondMonth}的{day}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency}{firstMonth}和{secondMonth}的{firstDay}和{secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency}{firstMonth}和{secondMonth}的{days}", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "連接 Google、Microsoft、Apple iCloud Calendar 或 Nextcloud 帳戶。", "googlePermissionsConsentNotice": "在 Google 權限畫面中,同時選取行事曆和待辦事項權限。", "googlePermissionsRequiredRetry": "必須授予 Google 日曆和 Google Tasks 權限。請再試一次並勾選兩個核取方塊。", "finishSetup": "完成設定", "continueSetup": "繼續", "onboardingSetupTitle": "設定 BusyMax", "onboardingAccountsStepTitle": "連結帳戶", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "新增所有要使用的帳戶。BusyMax 會同步每個帳戶中支援的行事曆、活動、待辦清單和待辦事項。", "onboardingPreferencesStepTitle": "選擇系統設定", "onboardingPreferencesStepDescription": "開啟行程前,請設定桌面行為、提醒、通知詳細程度和外觀。", "signInWithGoogle": "使用 Google 登入", @@ -33,6 +50,43 @@ "calendarTasks": "待辦事項", "calendar": "行事曆", "calendars": "行事曆", + "newCalendar": "新增行事曆", + "calendarColor": "行事曆顏色", + "calendarColorOption": "顏色 {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "此提供者不支援 BusyMax 中的行事曆管理。", + "primaryCalendarCannotDelete": "無法刪除主要行事曆。", + "calendarCreateFailed": "無法建立行事曆:{error}", + "calendarCreatedRefreshPending": "行事曆已建立,但 BusyMax 無法重新整理帳戶。它會在下次同步後顯示。", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "無法更新行事曆:{error}", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "無法刪除行事曆:{error}", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "新增活動", "refreshCalendar": "重新整理行事曆", "openInProvider": "在服務中開啟", @@ -51,7 +105,56 @@ "scheduleNoSearchResults": "沒有相符的活動或待辦事項", "scheduleNoSearchResultsDescription": "請嘗試其他搜尋內容或清除目前的篩選條件。", "refresh": "重新整理", - "trayOpenBusyMax": "開啟 BusyMax", + "trayOpenBusyMax": "打開 BusyMax", + "trayShowBusyMax": "顯示 BusyMax", + "trayNewEvent": "新增活動…", + "trayNewTask": "新增待辦事項…", + "trayToday": "今天", + "trayAllDay": "全天", + "trayNow": "現在", + "trayCalendarEvent": "行事曆活動", + "trayUntitledEvent": "未命名活動", + "trayNothingElseToday": "今天沒有其他內容", + "trayTasksDueToday": "{count, plural, =1{今天有 1 個任務到期} other{今天有 {count} 個任務到期}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "打開今天的日程", + "traySyncNow": "立即同步", + "traySyncing": "正在同步…", + "trayNotConnected": "未連接", + "trayNotYetSynced": "尚未同步", + "trayLastSyncedJustNow": "剛剛同步", + "trayLastSyncedMinutesAgo": "{count, plural, =1{1 分鐘前同步} other{{count} 分鐘前同步}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{1 小時前同步} other{{count} 小時前同步}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{1 天前同步} other{{count} 天前同步}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "設定", + "trayQuitBusyMax": "結束 BusyMax", "agendaLoadMoreOverdue": "載入更多逾期待辦事項", "agendaLoadMoreNoDate": "載入更多無日期待辦事項", "viewDay": "日", @@ -78,7 +181,7 @@ "doesNotRepeat": "不重複", "defaultReminder": "預設提醒", "guests": "參與者", - "noGuests": "沒有參與者", + "noGuests": "無參與者", "description": "說明", "availabilityShowAs": "空閒狀態 / 顯示為", "busy": "忙碌", @@ -154,7 +257,7 @@ "feedbackDetailedMessage": "詳細訊息", "feedbackReplyEmail": "回覆用電子郵件地址(選填)", "feedbackIncludeTechnicalDetails": "包含技術詳細資料", - "feedbackTechnicalDetailsDisclosure": "只會加入您的 Linux 作業系統版本和應用程式語系。不會包含記錄、帳戶資料、檔案名稱或其他診斷資訊。", + "feedbackTechnicalDetailsDisclosure": "只會加入您的作業系統名稱與版本和應用程式語系。不會包含記錄、帳戶資料、檔案名稱或其他診斷資訊。", "feedbackCategoryRequired": "請選擇類別。", "feedbackSubjectLengthError": "主旨必須介於 3 到 120 個字元之間。", "feedbackMessageLengthError": "訊息必須介於 10 到 5,000 個字元之間。", @@ -179,14 +282,14 @@ "removeAccount": "移除帳戶…", "removingAccount": "正在移除帳戶…", "removeAccountDescription": "停止同步並從此裝置移除此帳戶的資料。", - "removeAccountTitle": "要從 BusyMax 移除 {account} 嗎?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountTitle": "從 BusyMax 中移除 {account}?", + "removeAccountConfirmation": "這會從此設備刪除緩存的任務、日曆、活動、提醒和待處理的離線更改。未同步的更改將會丟失。提供商中的日曆、活動、任務列表和任務副本不會被刪除。", "revokeGoogleAccess": "同時撤銷 BusyMax 對此 Google 帳戶的存取權", "revokeGoogleAccessDescription": "重新連結前,您必須再次授予存取權。", "removeAccountAction": "移除帳戶", "removeAccountFailed": "無法完成帳戶移除。請再試一次。", "accountRemovedGoogleRevokeFailed": "該帳戶已從此裝置移除,但無法撤銷 BusyMax 對您的 Google 帳戶的存取權。您可以在 Google 帳戶中手動撤銷該權限。", - "newTaskList": "新增待辦清單", + "newTaskList": "新任務列表", "signInToViewTaskLists": "登入以查看待辦清單。", "noTaskListsSynced": "尚未同步任何待辦清單。", "listActions": "清單動作", @@ -196,7 +299,7 @@ "deleteList": "刪除清單", "builtInMicrosoftList": "內建", "builtInMicrosoftListCannotRenameDelete": "無法重新命名或刪除 Microsoft To Do 內建清單。", - "deleteListConfirmation": "要從 Google Tasks 刪除「{title}」嗎?", + "deleteListConfirmation": "要從 Google Tasks 中刪除“{title}”嗎?", "deleteEvent": "刪除活動", "title": "標題", "create": "新增", @@ -245,7 +348,7 @@ "reminderDate": "提醒日期", "reminderTime": "提醒時間", "reminder": "提醒", - "addReminder": "新增提醒", + "addReminder": "添加提醒", "addGuest": "新增參與者", "addGuestEmail": "新增參與者電子郵件", "removeReminder": "移除提醒", @@ -291,7 +394,8 @@ "synced": "已同步", "account": "帳戶", "sync": "同步", - "manualFullSync": "手動完整同步", + "forceFullResync": "強制完整重新同步", + "forceFullResyncDescription": "從每個已連結的帳號完整重新載入所有資料。僅在疑難排解同步問題時使用此功能。", "runInBackgroundWhenClosed": "視窗關閉後繼續在背景執行", "showTrayIcon": "顯示系統匣圖示", "startMinimizedToTray": "啟動時最小化至系統匣", @@ -305,6 +409,7 @@ "notifyConflicts": "衝突通知", "notifyDueToday": "今天到期待辦事項通知", "eventReminders": "活動提醒", + "onState": "開啟", "taskReminders": "待辦事項提醒", "notificationDetailLevel": "通知詳細程度", "notificationDetailPrivate": "私人", @@ -314,9 +419,13 @@ "quietHoursStart": "勿擾開始時間", "quietHoursEnd": "勿擾結束時間", "notifications": "通知", + "windowsNotificationsUnavailable": "Windows 通知無法使用", + "windowsNotificationsUnpackaged": "此未封裝的開發版本無法使用 Windows 通知。請安裝測試簽署的 MSIX 以測試提醒。", + "windowsNotificationsInstalledFailure": "BusyMax 無法初始化 Windows 通知。在解決此安裝問題之前,提醒不會顯示。", "appearance": "外觀", "theme": "主題", "themeSystem": "系統", + "settingsSystem": "系統", "themeLight": "淺色", "themeDark": "深色", "themeFamily": "主題系列", @@ -352,7 +461,7 @@ "syncFailureNotificationTitle": "BusyMax 同步失敗", "syncFailureNotificationBody": "背景同步失敗。{message}", "conflictNotificationTitle": "BusyMax 同步衝突", - "conflictNotificationBody": "一項待處理的本機變更遭到封鎖。{summary}", + "conflictNotificationBody": "一項擱置中的本機變更已被封鎖。{summary}", "dueTodayNotificationTitle": "今天到期的待辦事項", "dueTodayNotificationBody": "{count, plural, =1{今天有 1 項待辦事項到期。} other{今天有 {count} 項待辦事項到期。}}", "eventReminderNotificationTitle": "活動提醒", @@ -361,7 +470,7 @@ "taskReminderNotificationBody": "待辦事項即將到期。", "notificationOpenAction": "開啟", "notificationSnoozeAction": "10 分鐘後提醒", - "notificationDismissAction": "忽略", + "notificationDismissAction": "關閉", "notificationDetailsHidden": "根據隱私權設定,詳細資料已隱藏。", "previousMonth": "上個月", "nextMonth": "下個月", @@ -374,52 +483,52 @@ "scheduleItemCount": "{count, plural, =1{1 項} other{{count} 項}}", "readOnlyCalendar": "此行事曆為唯讀。", "selectTimeZone": "選擇時區", - "searchLocations": "搜尋地點", - "noLocationsFound": "找不到地點", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "searchLocations": "搜索地點", + "noLocationsFound": "未找到地點", + "requiredField": "此字段為必填項。", + "providerConnectionDescription": "連接以下任一提供商中的日曆和任務。", + "appleICloudProvider": "Apple iCloud 行事曆", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloud 待辦事項", + "addAppleICloudAccount": "新增 Apple iCloud 行事曆帳戶", + "addNextcloudAccount": "新增 Nextcloud 帳戶", + "waitingForAppleICloud": "正在連接 Apple iCloud…", + "waitingForNextcloud": "正在等待 Nextcloud 授權…", + "connectAppleICloudTitle": "連接 Apple iCloud 行事曆", + "appleAccountEmail": "Apple 賬戶電子郵件", + "appleAppSpecificPassword": "App 專用密碼", + "appleAppSpecificPasswordHelp": "為 Apple 賬戶啓用雙重認證後,創建 App 專用密碼。", + "appleAppSpecificPasswordResetWarning": "重置 Apple 賬戶密碼會撤銷 App 專用密碼。", + "connectNextcloudTitle": "連接 Nextcloud", + "nextcloudServerUrl": "Nextcloud 伺服器或 CalDAV 位址", + "nextcloudServerUrlHelp": "輸入 Nextcloud 服務器 URL,或粘貼從 Nextcloud 複製的主要 CalDAV 地址。", + "nextcloudBrowserAuthorizationHelp": "BusyMax 將打開瀏覽器。請在那裡批准訪問,然後返回 BusyMax。", + "connectAccountAction": "連接", + "cancelAccountConnection": "取消連接", + "nextcloudAccountRemovedRevokeFailed": "賬戶已在本地移除,但無法撤銷 Nextcloud App 密碼。", + "davCachedOfflineNotice": "日曆和任務數據會緩存在本地,以供離線使用。", + "davReauthenticationRequired": "重新連接此賬戶以恢復同步。", + "davTemporarilyUnavailable": "此賬戶暫時不可用。", + "davPermissionChanged": "服務器權限已更改。待處理的編輯已暫停。", + "davUnsupportedServer": "不支持此服務器或提供商配置。", + "collectionSettings": "行事曆和待辦清單", + "calendarContent": "行事曆活動", + "taskContent": "待辦事項", + "readOnlySharedCollection": "唯讀", + "pendingLocally": "本機擱置中", + "conflictBlocked": "因衝突而封鎖", + "authenticationBlocked": "重新連接前阻止", + "operationFailed": "操作失敗", + "keepServerVersion": "保留伺服器版本", + "reapplyLocalChange": "查看並重新應用本地更改", + "duplicateLocalItem": "複製為新項目", "deleteCalendarConfirmation": "要刪除「{title}」嗎?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "連線狀態", + "davConnected": "已連線", + "davConnecting": "正在連線…", + "davSignedOut": "已登出", + "davLastSuccessfulSync": "上次成功同步:{time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -427,9 +536,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "尚未同步", + "refreshCollections": "刷新日曆和任務列表", + "nextcloudServerHost": "服務器:{host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -437,12 +546,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "活動日曆", + "collectionSupportsTasks": "任務列表", + "collectionSupportsEventsAndTasks": "活動和任務", + "writableCollection": "可寫", + "sharedCollection": "已共享", + "collectionLastSynced": "上次同步:{time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -450,7 +559,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "同步問題:{code}", "@collectionSyncError": { "placeholders": { "code": { @@ -458,8 +567,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "同步衝突", + "remoteChangedAt": "服務器更改:{time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -467,7 +576,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "本地編輯:{summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -475,144 +584,366 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "conflictResolutionFailed": "無法解決衝突。", + "recurringEventScope": "重複活動範圍", + "entireSeries": "整個系列", + "singleOccurrence": "此事件", + "thisAndFollowingEvents": "此事件及後續事件", + "thisAndFutureUnavailable": "此提供者不支援。", + "thisAndFutureMoveUnavailable": "無法安全移動此活動及後續活動。請選擇此活動或整個系列。", + "entireSeriesMoveUnavailable": "本機沒有可用的重複規則。請只移動此活動。", + "copyEventAndDeleteOriginal": "複製活動並刪除原活動?", + "copyEventMoveWarning": "BusyMax 無法將此活動直接從 {source} 移動到 {destination}。應用程式會先建立副本,並且只在複製成功後刪除原活動。活動 ID 將會變更;參與者的回覆狀態可能會重設,並可能傳送邀請或取消通知;會議連結、附件、提醒、提供者特有的欄位和重複例外可能無法保留。", + "copyAndDelete": "複製並刪除", + "chooseRecurringEventScope": "選擇此變更適用於整個系列、僅此活動,還是此活動及後續活動。", "taskDueBeforeStart": "到期時間不得早於開始時間。", - "taskStartDueTimeModeMismatch": "請同時設定開始與到期時間,或將工作設為全天。", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", - "unshare": "撤回分享", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "taskStartDueTimeModeMismatch": "請同時設定開始與到期時間,或將待辦事項設為全天。", + "taskListCreateFailed": "無法創建任務列表:{error}", + "taskListRenameFailed": "無法重命名任務列表:{error}", + "taskListDeleteFailed": "無法刪除任務列表:{error}", + "unshare": "取消共享", + "readOnlyTaskListCannotRename": "此任務列表為只讀,無法重命名。", + "taskListCannotDelete": "使用當前權限無法刪除此任務列表。", + "deleteTaskListConfirmation": "要刪除“{title}”及其所有任務嗎?", + "unshareTaskListConfirmation": "要取消此賬戶對“{title}”的共享嗎?", + "taskStatus": "狀態", + "taskStatusNone": "無狀態", "taskStatusNeedsAction": "需要動作", "taskStatusInProcess": "進行中", - "taskStatusCompleted": "完成", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", - "priority": "優先", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", + "taskStatusCompleted": "已完成", + "taskStatusCancelled": "已取消", + "completionPercent": "已完成 {percent}%", + "completionDate": "完成日期", + "priority": "優先順序", + "priorityNone": "無優先順序", + "priorityHighValue": "優先級 {priority} · 高", + "priorityMediumValue": "優先級 {priority} · 中", + "priorityLowValue": "優先級 {priority} · 低", + "taskUrl": "任務 URL", + "invalidTaskUrl": "請輸入包含方案的絕對 URL。", + "classification": "分類", + "classificationPublic": "共享時顯示完整任務", + "classificationConfidential": "共享時僅顯示忙碌狀態", + "classificationPrivate": "共享時隱藏此任務", + "pinTask": "固定任務", + "reminders": "提醒", "noReminders": "無提醒", - "editReminder": "Edit reminder", - "beforeTaskStarts": "在任務開始前", - "beforeTaskDue": "任務截止前", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", + "editReminder": "編輯提醒", + "beforeTaskStarts": "待辦事項開始前", + "beforeTaskDue": "待辦事項到期前", + "afterTaskStarts": "任務開始後", + "afterTaskDue": "任務到期後", + "relativeToTaskStart": "相對於任務開始日期", + "relativeToTaskDue": "相對於任務到期日期", + "reminderTimeOfDay": "時間", + "absoluteReminder": "在指定日期和時間", + "reminderAmount": "數量", + "reminderUnitSeconds": "秒", + "reminderUnitMinutes": "分鐘", + "reminderUnitHours": "小時", + "reminderUnitDays": "天", + "reminderUnitWeeks": "週", + "reminderAtTaskStart": "任務開始時", + "reminderAtTaskDue": "任務到期時", + "unsupportedReminder": "此提醒類型會保留,但無法編輯其時間。", + "relatedRemindersTitle": "保留相關提醒?", + "relatedRemindersDescription": "此日期有 {count} 個相關提醒。要保留它們當前的日期和時間嗎?", "discardRelatedReminders": "捨棄提醒", "keepRelatedReminders": "保留提醒", - "repeatEvery": "重複循環", - "repeatOn": "Repeat on", - "repeatEnd": "停止重複", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "於 {days}", - "repeatOnMonthDaysSummary": "於 {days} 天", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", + "repeatEvery": "重複間隔", + "repeatOn": "重複日期", + "repeatEnd": "結束重複", + "repeatNever": "永不", + "repeatUntil": "指定日期", + "repeatAfter": "指定次數後", + "repeatCount": "重複次數", + "repeatDayOfMonth": "每月日期", + "repeatMonths": "月份", + "repeatOrdinal": "星期位置", + "repeatSpecificDays": "特定日期", + "repeatFirst": "第一", + "repeatSecond": "第二", + "repeatThird": "第三", + "repeatFourth": "第四", + "repeatFifth": "第五", + "repeatSecondToLast": "倒數第二", + "repeatLast": "最後", + "repeatAnyDay": "日期", + "repeatWeekday": "工作日", + "repeatWeekendDay": "週末", + "repeatOrdinalDaySummary": "{dayKey, select, MO{星期一} TU{星期二} WE{星期三} TH{星期四} FR{星期五} SA{星期六} SU{星期日} day{日期} weekday{工作日} weekend{週末} other{{day}}}", + "repeatEveryDays": "每 {count} 天", + "repeatEveryWeeks": "每 {count} 週", + "repeatEveryMonths": "每 {count} 個月", + "repeatEveryYears": "每 {count} 年", + "repeatOnDaysSummary": "在 {days}", + "repeatOnMonthDaysSummary": "{days}", + "repeatOnOrdinalSummary": "{position, select, first{第一個{days}} second{第二個{days}} third{第三個{days}} fourth{第四個{days}} fifth{第五個{days}} secondToLast{倒數第二個{days}} last{最後一個{days}} other{{days}}}", "repeatInMonthsSummary": "在 {months}", - "repeatTimesSummary": "{count} 次", - "repeatUntilSummary": "到 {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", - "duplicateTask": "再製任務", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "repeatTimesSummary": "{count, plural, other{重複 {count} 次}}", + "repeatUntilSummary": "直到 {date}", + "unsupportedRecurrencePreserved": "此重複規則使用了編輯器不會更改的選項。", + "recurrenceUnsupportedByProvider": "此重複規則無法用於 {provider}。", + "duplicateTask": "複製待辦事項", + "taskDuplicated": "任務已複製。", + "taskDuplicateFailed": "無法複製任務:{error}", "hideSubtasks": "隱藏子工作項目", "hideClosedSubtasks": "隱藏已關閉的子工作項目", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "reminderUnit": "單位", + "attendeeRequired": "必要", + "attendeeOptional": "選用", + "meetingSection": "會議", + "addGoogleMeet": "添加 Google Meet", + "addTeamsMeeting": "添加 Microsoft Teams 會議", + "onlineMeetingAdded": "已添加在線會議", + "requestResponses": "要求回覆", + "requestResponsesDescription": "要求參與者回復邀請。", + "hideGuestList": "隱藏參與者清單", + "hideGuestListDescription": "參與者無法查看其他受邀者。", + "allowNewTimeProposals": "允許提出新時間", + "allowNewTimeProposalsDescription": "參與者可以建議其他會議時間。", + "notifyGuestsTitle": "通知參與者?", + "notifyGuestsSaveMessage": "此會議有參與者。保存時發送邀請或活動更新嗎?", + "notifyGuestsDeleteMessage": "此會議有參與者。刪除時發送取消通知嗎?", + "sendUpdates": "傳送更新", + "sendCancellation": "傳送取消通知", + "doNotSend": "不要傳送", + "microsoftNotifyGuestsSaveTitle": "保存會議?", + "microsoftNotifyGuestsSaveMessage": "Microsoft 將向參與者發送邀請或活動更新。", + "microsoftNotifyGuestsDeleteTitle": "刪除會議?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft 將向參與者發送取消通知。", + "organizer": "主辦人", + "yourResponse": "你的回覆", + "guestResponses": "參與者回覆", + "respond": "回復", + "acceptInvitation": "接受", + "tentativeInvitation": "暫定", + "declineInvitation": "拒絕", + "joinMeeting": "加入會議", + "responseAccepted": "已接受", + "responseTentative": "暫定", + "responseDeclined": "已拒絕", + "responseNeedsAction": "等待回復", + "responseNotResponded": "未回復", + "responseOrganizer": "組織者", + "invitationResponseFailed": "無法發送回復:{error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "無法打開會議鏈接。", + "setCustomCalendarName": "設定自訂名稱", + "setAction": "設定", + "removeFromMyCalendars": "從「我的日曆」移除", + "removeAction": "移除", + "removeCalendarConfirmation": "要從您的 Google 日曆清單移除「{title}」嗎?共用日曆及其活動不會被刪除。", + "calendarCannotRemove": "無法從此賬戶刪除或移除此日曆。", + "calendarPendingChangesPreventRemoval": "請等待此日曆的待處理更改完成同步,然後再刪除或移除它。", + "calendarSubscriptions": "行事曆訂閱", + "calendarSubscriptionsDescription": "添加從安全 WebCal URL 刷新的只讀日曆。", + "addCalendarSubscription": "新增行事曆訂閱", + "subscriptionName": "本機名稱", + "subscriptionUrl": "訂閱 URL", + "subscriptionUrlHelp": "輸入 HTTPS 或 webcal URL。BusyMax 會將完整 URL 保存在安全存儲中。", + "subscriptionUrlInvalid": "請輸入不含用戶信息或片段的有效 HTTPS 或 webcal URL。", + "subscriptionColor": "本地顏色", + "subscriptionColorHelp": "使用六位顏色,例如 #3584E4。", + "subscriptionColorInvalid": "請輸入六位十六進制顏色。", + "subscriptionRefreshMode": "刷新頻率", + "subscriptionAutomatic": "自動", + "subscriptionHourly": "每小時", + "subscriptionSixHours": "每六小時", + "subscriptionDaily": "每天", + "subscriptionSafeOrigin": "來源:{origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "請輸入有效 URL 以預覽其安全來源。", + "subscriptionReadOnly": "只讀訂閱", + "subscriptionNeverRefreshed": "尚未刷新", + "subscriptionLastRefresh": "上次成功刷新:{time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "下次刷新:{time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "已是最新", + "subscriptionStatusIssue": "刷新問題:{code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "立即重新整理", + "unsubscribe": "取消訂閱", + "unsubscribeCalendarTitle": "要取消訂閱“{name}”嗎?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "這會移除本地訂閱及其緩存的活動。已發佈的日曆不會更改。", + "addSubscriptionAction": "添加訂閱", + "subscriptionOperationFailed": "日曆訂閱失敗:{error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "訂閱", + "calendarImport": "匯入行事曆", + "calendarImportDescription": "選擇文件,查看其中的活動,然後選擇接收這些活動的可寫日曆。", + "importIcsFile": "匯入 .ics 檔案", + "importIcsPreview": "匯入行事曆活動", + "importEventsFound": "可導入活動集:{count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "無效活動:{count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "有意省略:{fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "沒有可用的可寫目標日曆。", + "importDestinationCalendar": "目標日曆", + "importIcsConfirm": "導入活動", + "importIcsComplete": "匯入完成", + "importQueued": "已導入或已排隊:{count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "已跳過重複項:{count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "不支持的重複集:{count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "無法導入日曆文件:{error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "離線", "networkOfflineDescription": "連線恢復後將同步變更。", - "networkOfflineTryAgain": "您目前處於離線狀態。請連接網際網路後再試一次。" + "networkOfflineTryAgain": "您目前處於離線狀態。請連接網際網路後再試一次。", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "{days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": "", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}日", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first}和{second}", + "repeatWeekdayListStart": "{first}、{rest}", + "repeatMonthDayListPair": "{first}、{second}", + "repeatMonthDayListStart": "{first}、{rest}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first}和{second}", + "repeatYearlyMonthDayListStart": "{first}、{rest}", + "repeatYearlyMonthListPair": "{first}和{second}", + "repeatYearlyMonthListStart": "{first}、{rest}", + "repeatYearlyOnMonthDaySummary": "{frequency}{month}{day}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency}{months}的{day}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency}{months}的{days}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency}{months}的{position, select, first{第一個{days}} second{第二個{days}} third{第三個{days}} fourth{第四個{days}} fifth{第五個{days}} secondToLast{倒數第二個{days}} last{最後一個{days}} other{{days}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency}{month}的{days}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency}{month}的{position, select, first{第一個{days}} second{第二個{days}} third{第三個{days}} fourth{第四個{days}} fifth{第五個{days}} secondToLast{倒數第二個{days}} last{最後一個{days}} other{{days}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 02e7d186..88e47216 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -125,6 +125,7 @@ abstract class AppLocalizations { Locale('ja'), Locale('ko'), Locale('pt'), + Locale('pt', 'PT'), Locale('ru'), Locale('vi'), Locale('zh'), @@ -132,13 +133,136 @@ abstract class AppLocalizations { Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), ]; + /// No description provided for @windowsSupport. + /// + /// In en, this message translates to: + /// **'Support'** + String get windowsSupport; + + /// No description provided for @windowsThirdPartyLicenses. + /// + /// In en, this message translates to: + /// **'Third-party licenses'** + String get windowsThirdPartyLicenses; + + /// No description provided for @windowsSearch. + /// + /// In en, this message translates to: + /// **'Search'** + String get windowsSearch; + + /// No description provided for @windowsStartupDisabledByUser. + /// + /// In en, this message translates to: + /// **'Disabled by the user in Windows Settings.'** + String get windowsStartupDisabledByUser; + + /// No description provided for @windowsStartupDisabledByPolicy. + /// + /// In en, this message translates to: + /// **'Disabled by Windows policy.'** + String get windowsStartupDisabledByPolicy; + + /// No description provided for @windowsStartupUnavailable. + /// + /// In en, this message translates to: + /// **'Available after BusyMax is installed from an MSIX package.'** + String get windowsStartupUnavailable; + + /// No description provided for @windowsReminderExitNotice. + /// + /// In en, this message translates to: + /// **'Reminders stop when BusyMax is fully quit. Keep it running in the background to receive them.'** + String get windowsReminderExitNotice; + + /// No description provided for @windowsProductVersionLabel. + /// + /// In en, this message translates to: + /// **'Product version'** + String get windowsProductVersionLabel; + + /// No description provided for @windowsPackageVersionLabel. + /// + /// In en, this message translates to: + /// **'Windows package version'** + String get windowsPackageVersionLabel; + + /// No description provided for @windowsUnpackaged. + /// + /// In en, this message translates to: + /// **'Unpackaged'** + String get windowsUnpackaged; + + /// No description provided for @windowsAgendaLoadMore. + /// + /// In en, this message translates to: + /// **'Load more agenda items'** + String get windowsAgendaLoadMore; + + /// Sentence-form weekday used inside a weekly recurrence summary. {dayKey} is MO through SU; {day} is the locale-formatted fallback. + /// + /// In en, this message translates to: + /// **'{dayKey, select, MO{Monday} TU{Tuesday} WE{Wednesday} TH{Thursday} FR{Friday} SA{Saturday} SU{Sunday} other{{day}}}'** + String repeatWeeklyDaySummary(String dayKey, String day); + + /// Sentence fragment for a monthly recurrence on exactly two dates. + /// + /// In en, this message translates to: + /// **'on days {first} and {second}'** + String repeatOnTwoMonthDaysSummary(String first, String second); + + /// Complete yearly recurrence summary for exactly two dates in one month. + /// + /// In en, this message translates to: + /// **'{frequency} on days {firstDay} and {secondDay} of {month}'** + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ); + + /// Complete yearly recurrence summary for one date in exactly two months. + /// + /// In en, this message translates to: + /// **'{frequency} on day {day} of {firstMonth} and {secondMonth}'** + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ); + + /// Complete yearly recurrence summary for exactly two dates in exactly two months. + /// + /// In en, this message translates to: + /// **'{frequency} on days {firstDay} and {secondDay} of {firstMonth} and {secondMonth}'** + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ); + + /// Complete yearly recurrence summary for three or more dates in exactly two months. + /// + /// In en, this message translates to: + /// **'{frequency} on days {days} of {firstMonth} and {secondMonth}'** + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ); + /// No description provided for @appTitle. /// /// In en, this message translates to: /// **'BusyMax'** String get appTitle; - /// No description provided for @connectGoogleAccount. + /// Shown in account onboarding and settings; names the supported account providers. /// /// In en, this message translates to: /// **'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'** @@ -180,7 +304,7 @@ abstract class AppLocalizations { /// **'Connect accounts'** String get onboardingAccountsStepTitle; - /// No description provided for @onboardingAccountsStepDescription. + /// Onboarding explanation of the account data BusyMax synchronizes. /// /// In en, this message translates to: /// **'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'** @@ -330,6 +454,60 @@ abstract class AppLocalizations { /// **'Calendars'** String get calendars; + /// No description provided for @newCalendar. + /// + /// In en, this message translates to: + /// **'New calendar'** + String get newCalendar; + + /// No description provided for @calendarColor. + /// + /// In en, this message translates to: + /// **'Calendar color'** + String get calendarColor; + + /// No description provided for @calendarColorOption. + /// + /// In en, this message translates to: + /// **'Color {number}'** + String calendarColorOption(int number); + + /// No description provided for @calendarManagementUnsupported. + /// + /// In en, this message translates to: + /// **'This provider does not support calendar management in BusyMax.'** + String get calendarManagementUnsupported; + + /// No description provided for @primaryCalendarCannotDelete. + /// + /// In en, this message translates to: + /// **'The primary calendar cannot be deleted.'** + String get primaryCalendarCannotDelete; + + /// No description provided for @calendarCreateFailed. + /// + /// In en, this message translates to: + /// **'Could not create the calendar: {error}'** + String calendarCreateFailed(String error); + + /// No description provided for @calendarCreatedRefreshPending. + /// + /// In en, this message translates to: + /// **'The calendar was created, but BusyMax could not refresh the account. It will appear after the next sync.'** + String get calendarCreatedRefreshPending; + + /// No description provided for @calendarUpdateFailed. + /// + /// In en, this message translates to: + /// **'Could not update the calendar: {error}'** + String calendarUpdateFailed(String error); + + /// No description provided for @calendarDeleteFailed. + /// + /// In en, this message translates to: + /// **'Could not delete the calendar: {error}'** + String calendarDeleteFailed(String error); + /// No description provided for @newEvent. /// /// In en, this message translates to: @@ -444,6 +622,132 @@ abstract class AppLocalizations { /// **'Open BusyMax'** String get trayOpenBusyMax; + /// No description provided for @trayShowBusyMax. + /// + /// In en, this message translates to: + /// **'Show BusyMax'** + String get trayShowBusyMax; + + /// No description provided for @trayNewEvent. + /// + /// In en, this message translates to: + /// **'New event…'** + String get trayNewEvent; + + /// No description provided for @trayNewTask. + /// + /// In en, this message translates to: + /// **'New task…'** + String get trayNewTask; + + /// No description provided for @trayToday. + /// + /// In en, this message translates to: + /// **'Today'** + String get trayToday; + + /// No description provided for @trayAllDay. + /// + /// In en, this message translates to: + /// **'All day'** + String get trayAllDay; + + /// No description provided for @trayNow. + /// + /// In en, this message translates to: + /// **'Now'** + String get trayNow; + + /// No description provided for @trayCalendarEvent. + /// + /// In en, this message translates to: + /// **'Calendar event'** + String get trayCalendarEvent; + + /// No description provided for @trayUntitledEvent. + /// + /// In en, this message translates to: + /// **'Untitled event'** + String get trayUntitledEvent; + + /// No description provided for @trayNothingElseToday. + /// + /// In en, this message translates to: + /// **'Nothing else today'** + String get trayNothingElseToday; + + /// No description provided for @trayTasksDueToday. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 task due today} other{{count} tasks due today}}'** + String trayTasksDueToday(int count); + + /// No description provided for @trayOpenTodayAgenda. + /// + /// In en, this message translates to: + /// **'Open today’s agenda'** + String get trayOpenTodayAgenda; + + /// No description provided for @traySyncNow. + /// + /// In en, this message translates to: + /// **'Sync now'** + String get traySyncNow; + + /// No description provided for @traySyncing. + /// + /// In en, this message translates to: + /// **'Syncing…'** + String get traySyncing; + + /// No description provided for @trayNotConnected. + /// + /// In en, this message translates to: + /// **'Not connected'** + String get trayNotConnected; + + /// No description provided for @trayNotYetSynced. + /// + /// In en, this message translates to: + /// **'Not yet synced'** + String get trayNotYetSynced; + + /// No description provided for @trayLastSyncedJustNow. + /// + /// In en, this message translates to: + /// **'Last synced just now'** + String get trayLastSyncedJustNow; + + /// No description provided for @trayLastSyncedMinutesAgo. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}'** + String trayLastSyncedMinutesAgo(int count); + + /// No description provided for @trayLastSyncedHoursAgo. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}'** + String trayLastSyncedHoursAgo(int count); + + /// No description provided for @trayLastSyncedDaysAgo. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}'** + String trayLastSyncedDaysAgo(int count); + + /// No description provided for @traySettings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get traySettings; + + /// No description provided for @trayQuitBusyMax. + /// + /// In en, this message translates to: + /// **'Quit BusyMax'** + String get trayQuitBusyMax; + /// No description provided for @agendaLoadMoreOverdue. /// /// In en, this message translates to: @@ -1287,7 +1591,7 @@ abstract class AppLocalizations { /// No description provided for @feedbackTechnicalDetailsDisclosure. /// /// In en, this message translates to: - /// **'Adds only your Linux operating-system version and application locale. No logs, account data, file names, or other diagnostics are included.'** + /// **'Adds only your operating-system name and version and application locale. No logs, account data, file names, or other diagnostics are included.'** String get feedbackTechnicalDetailsDisclosure; /// No description provided for @feedbackCategoryRequired. @@ -1833,7 +2137,7 @@ abstract class AppLocalizations { /// No description provided for @taskStatusInProcess. /// /// In en, this message translates to: - /// **'In process'** + /// **'In progress'** String get taskStatusInProcess; /// No description provided for @taskStatusCompleted. @@ -2028,7 +2332,7 @@ abstract class AppLocalizations { /// **'Before the task starts'** String get beforeTaskStarts; - /// No description provided for @beforeTaskDue. + /// Reminder timing relative to the task's due time/deadline, not task completion. /// /// In en, this message translates to: /// **'Before the task is due'** @@ -2220,13 +2524,13 @@ abstract class AppLocalizations { /// **'Yearly'** String get repeatYearly; - /// No description provided for @repeatEvery. + /// Standalone label for the numeric interval used by daily, weekly, monthly, and yearly recurrence. /// /// In en, this message translates to: - /// **'Repeat every'** + /// **'Interval'** String get repeatEvery; - /// No description provided for @repeatOn. + /// Recurrence editor label introducing the selected weekdays, month days, or ordinal weekday. /// /// In en, this message translates to: /// **'Repeat on'** @@ -2346,6 +2650,12 @@ abstract class AppLocalizations { /// **'Weekend day'** String get repeatWeekendDay; + /// Sentence-form day label used inside ordinal recurrence summaries. {dayKey} is MO through SU, day, weekday, or weekend; {day} is the locale-formatted weekday fallback. + /// + /// In en, this message translates to: + /// **'{dayKey, select, MO{Monday} TU{Tuesday} WE{Wednesday} TH{Thursday} FR{Friday} SA{Saturday} SU{Sunday} day{day} weekday{weekday} weekend{weekend day} other{{day}}}'** + String repeatOrdinalDaySummary(String dayKey, String day); + /// No description provided for @repeatEveryDays. /// /// In en, this message translates to: @@ -2370,37 +2680,37 @@ abstract class AppLocalizations { /// **'Every {count} years'** String repeatEveryYears(int count); - /// No description provided for @repeatOnDaysSummary. + /// Sentence fragment appended to a recurrence summary; {days} is a localized list of weekdays. /// /// In en, this message translates to: /// **'on {days}'** String repeatOnDaysSummary(String days); - /// No description provided for @repeatOnMonthDaysSummary. + /// Sentence fragment appended to a recurrence summary; {days} is a localized list of day-of-month numbers. /// /// In en, this message translates to: /// **'on day {days}'** String repeatOnMonthDaysSummary(String days); - /// No description provided for @repeatOnOrdinalSummary. + /// Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday. /// /// In en, this message translates to: - /// **'on the {ordinal} {days}'** - String repeatOnOrdinalSummary(String ordinal, String days); + /// **'{position, select, first{on the first {days}} second{on the second {days}} third{on the third {days}} fourth{on the fourth {days}} fifth{on the fifth {days}} secondToLast{on the second to last {days}} last{on the last {days}} other{on {days}}}'** + String repeatOnOrdinalSummary(String position, String days); - /// No description provided for @repeatInMonthsSummary. + /// Sentence fragment for selected recurrence months; {months} is a localized list of month names. /// /// In en, this message translates to: /// **'in {months}'** String repeatInMonthsSummary(String months); - /// No description provided for @repeatTimesSummary. + /// Sentence fragment summarizing a finite recurrence count. /// /// In en, this message translates to: - /// **'{count} times'** + /// **'{count, plural, one{{count} time} other{{count} times}}'** String repeatTimesSummary(int count); - /// No description provided for @repeatUntilSummary. + /// Sentence fragment for the recurrence end date. /// /// In en, this message translates to: /// **'until {date}'** @@ -2412,6 +2722,12 @@ abstract class AppLocalizations { /// **'This recurrence rule uses options that this editor does not change.'** String get unsupportedRecurrencePreserved; + /// No description provided for @recurrenceUnsupportedByProvider. + /// + /// In en, this message translates to: + /// **'This recurrence cannot be used with {provider}.'** + String recurrenceUnsupportedByProvider(String provider); + /// No description provided for @importance. /// /// In en, this message translates to: @@ -2592,7 +2908,7 @@ abstract class AppLocalizations { /// **'Updated'** String get updated; - /// No description provided for @parent. + /// Metadata label for the immediate parent task of a subtask. /// /// In en, this message translates to: /// **'Parent'** @@ -2646,11 +2962,17 @@ abstract class AppLocalizations { /// **'Sync'** String get sync; - /// No description provided for @manualFullSync. + /// Troubleshooting action that forces a complete resynchronization of every connected account. + /// + /// In en, this message translates to: + /// **'Force full resync'** + String get forceFullResync; + + /// Explanation shown above the force-full-resync troubleshooting action in Diagnostics. /// /// In en, this message translates to: - /// **'Manual full sync'** - String get manualFullSync; + /// **'Completely reload data from every connected account. Use this only to troubleshoot synchronization problems.'** + String get forceFullResyncDescription; /// No description provided for @runInBackgroundWhenClosed. /// @@ -2730,6 +3052,12 @@ abstract class AppLocalizations { /// **'Event reminders'** String get eventReminders; + /// No description provided for @onState. + /// + /// In en, this message translates to: + /// **'On'** + String get onState; + /// No description provided for @taskReminders. /// /// In en, this message translates to: @@ -2784,6 +3112,24 @@ abstract class AppLocalizations { /// **'Notifications'** String get notifications; + /// No description provided for @windowsNotificationsUnavailable. + /// + /// In en, this message translates to: + /// **'Windows notifications are unavailable'** + String get windowsNotificationsUnavailable; + + /// No description provided for @windowsNotificationsUnpackaged. + /// + /// In en, this message translates to: + /// **'This unpackaged development run cannot use Windows notifications. Install the test-signed MSIX to test reminders.'** + String get windowsNotificationsUnpackaged; + + /// No description provided for @windowsNotificationsInstalledFailure. + /// + /// In en, this message translates to: + /// **'BusyMax could not initialize Windows notifications. Reminders will not appear until this installation problem is resolved.'** + String get windowsNotificationsInstalledFailure; + /// No description provided for @appearance. /// /// In en, this message translates to: @@ -2802,6 +3148,12 @@ abstract class AppLocalizations { /// **'System'** String get themeSystem; + /// Standalone noun used for the System section in the Settings sidebar and page heading. + /// + /// In en, this message translates to: + /// **'System'** + String get settingsSystem; + /// No description provided for @themeLight. /// /// In en, this message translates to: @@ -3066,7 +3418,7 @@ abstract class AppLocalizations { /// **'Snooze 10 minutes'** String get notificationSnoozeAction; - /// No description provided for @notificationDismissAction. + /// Non-destructive action that closes or dismisses a desktop notification. Translate distinctly from reject, discard, or cancel. /// /// In en, this message translates to: /// **'Dismiss'** @@ -3513,19 +3865,55 @@ abstract class AppLocalizations { /// No description provided for @singleOccurrence. /// /// In en, this message translates to: - /// **'This occurrence'** + /// **'This event'** String get singleOccurrence; + /// No description provided for @thisAndFollowingEvents. + /// + /// In en, this message translates to: + /// **'This and following events'** + String get thisAndFollowingEvents; + /// No description provided for @thisAndFutureUnavailable. /// /// In en, this message translates to: - /// **'This and future (not available)'** + /// **'Not supported by this provider.'** String get thisAndFutureUnavailable; - /// No description provided for @chooseRecurringEventScope. + /// No description provided for @thisAndFutureMoveUnavailable. + /// + /// In en, this message translates to: + /// **'This and following events cannot be moved safely. Choose this event or the entire series.'** + String get thisAndFutureMoveUnavailable; + + /// No description provided for @entireSeriesMoveUnavailable. + /// + /// In en, this message translates to: + /// **'The recurrence rule is not available locally. Move this event instead.'** + String get entireSeriesMoveUnavailable; + + /// No description provided for @copyEventAndDeleteOriginal. /// /// In en, this message translates to: - /// **'Choose whether this change applies to the entire series or only this occurrence.'** + /// **'Copy event and delete original?'** + String get copyEventAndDeleteOriginal; + + /// No description provided for @copyEventMoveWarning. + /// + /// In en, this message translates to: + /// **'BusyMax cannot move this event directly from {source} to {destination}. It will create the copy first and delete the original only after the copy succeeds. Event IDs will change; attendee response statuses may reset and invitations or cancellations may be sent; conference links, attachments, reminders, provider-specific fields, and recurrence exceptions may not carry over.'** + String copyEventMoveWarning(String source, String destination); + + /// No description provided for @copyAndDelete. + /// + /// In en, this message translates to: + /// **'Copy and delete'** + String get copyAndDelete; + + /// Explains the three recurring-event scope choices: entire series, one occurrence, or this and following events. + /// + /// In en, this message translates to: + /// **'Choose whether this change applies to the entire series, only this occurrence, or this and following events.'** String get chooseRecurringEventScope; /// No description provided for @taskDueBeforeStart. @@ -3546,6 +3934,318 @@ abstract class AppLocalizations { /// **'Delete \"{title}\"?'** String deleteCalendarConfirmation(String title); + /// No description provided for @setCustomCalendarName. + /// + /// In en, this message translates to: + /// **'Set custom name'** + String get setCustomCalendarName; + + /// No description provided for @setAction. + /// + /// In en, this message translates to: + /// **'Set'** + String get setAction; + + /// No description provided for @removeFromMyCalendars. + /// + /// In en, this message translates to: + /// **'Remove from my calendars'** + String get removeFromMyCalendars; + + /// No description provided for @removeAction. + /// + /// In en, this message translates to: + /// **'Remove'** + String get removeAction; + + /// No description provided for @removeCalendarConfirmation. + /// + /// In en, this message translates to: + /// **'Remove \"{title}\" from your Google Calendar list? The shared calendar and its events will not be deleted.'** + String removeCalendarConfirmation(String title); + + /// No description provided for @calendarCannotRemove. + /// + /// In en, this message translates to: + /// **'This calendar cannot be deleted or removed from this account.'** + String get calendarCannotRemove; + + /// No description provided for @calendarPendingChangesPreventRemoval. + /// + /// In en, this message translates to: + /// **'Wait for this calendar’s pending changes to finish syncing before deleting or removing it.'** + String get calendarPendingChangesPreventRemoval; + + /// No description provided for @calendarSubscriptions. + /// + /// In en, this message translates to: + /// **'Calendar subscriptions'** + String get calendarSubscriptions; + + /// No description provided for @calendarSubscriptionsDescription. + /// + /// In en, this message translates to: + /// **'Add read-only calendars that refresh from a secure WebCal URL.'** + String get calendarSubscriptionsDescription; + + /// No description provided for @addCalendarSubscription. + /// + /// In en, this message translates to: + /// **'Add calendar subscription'** + String get addCalendarSubscription; + + /// No description provided for @subscriptionName. + /// + /// In en, this message translates to: + /// **'Local name'** + String get subscriptionName; + + /// No description provided for @subscriptionUrl. + /// + /// In en, this message translates to: + /// **'Subscription URL'** + String get subscriptionUrl; + + /// No description provided for @subscriptionUrlHelp. + /// + /// In en, this message translates to: + /// **'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'** + String get subscriptionUrlHelp; + + /// No description provided for @subscriptionUrlInvalid. + /// + /// In en, this message translates to: + /// **'Enter a valid HTTPS or webcal URL without user information or a fragment.'** + String get subscriptionUrlInvalid; + + /// No description provided for @subscriptionColor. + /// + /// In en, this message translates to: + /// **'Local color'** + String get subscriptionColor; + + /// No description provided for @subscriptionColorHelp. + /// + /// In en, this message translates to: + /// **'Use a six-digit color such as #3584E4.'** + String get subscriptionColorHelp; + + /// No description provided for @subscriptionColorInvalid. + /// + /// In en, this message translates to: + /// **'Enter a six-digit hexadecimal color.'** + String get subscriptionColorInvalid; + + /// No description provided for @subscriptionRefreshMode. + /// + /// In en, this message translates to: + /// **'Refresh frequency'** + String get subscriptionRefreshMode; + + /// No description provided for @subscriptionAutomatic. + /// + /// In en, this message translates to: + /// **'Automatic'** + String get subscriptionAutomatic; + + /// No description provided for @subscriptionHourly. + /// + /// In en, this message translates to: + /// **'Hourly'** + String get subscriptionHourly; + + /// No description provided for @subscriptionSixHours. + /// + /// In en, this message translates to: + /// **'Every six hours'** + String get subscriptionSixHours; + + /// No description provided for @subscriptionDaily. + /// + /// In en, this message translates to: + /// **'Daily'** + String get subscriptionDaily; + + /// No description provided for @subscriptionSafeOrigin. + /// + /// In en, this message translates to: + /// **'Source: {origin}'** + String subscriptionSafeOrigin(String origin); + + /// No description provided for @subscriptionSafeOriginUnavailable. + /// + /// In en, this message translates to: + /// **'Enter a valid URL to preview its safe origin.'** + String get subscriptionSafeOriginUnavailable; + + /// No description provided for @subscriptionReadOnly. + /// + /// In en, this message translates to: + /// **'Read-only subscription'** + String get subscriptionReadOnly; + + /// No description provided for @subscriptionNeverRefreshed. + /// + /// In en, this message translates to: + /// **'Not refreshed yet'** + String get subscriptionNeverRefreshed; + + /// No description provided for @subscriptionLastRefresh. + /// + /// In en, this message translates to: + /// **'Last successful refresh: {time}'** + String subscriptionLastRefresh(String time); + + /// No description provided for @subscriptionNextRefresh. + /// + /// In en, this message translates to: + /// **'Next refresh: {time}'** + String subscriptionNextRefresh(String time); + + /// No description provided for @subscriptionStatusHealthy. + /// + /// In en, this message translates to: + /// **'Up to date'** + String get subscriptionStatusHealthy; + + /// No description provided for @subscriptionStatusIssue. + /// + /// In en, this message translates to: + /// **'Refresh issue: {code}'** + String subscriptionStatusIssue(String code); + + /// No description provided for @refreshNow. + /// + /// In en, this message translates to: + /// **'Refresh now'** + String get refreshNow; + + /// No description provided for @unsubscribe. + /// + /// In en, this message translates to: + /// **'Unsubscribe'** + String get unsubscribe; + + /// No description provided for @unsubscribeCalendarTitle. + /// + /// In en, this message translates to: + /// **'Unsubscribe from “{name}”?'** + String unsubscribeCalendarTitle(String name); + + /// No description provided for @unsubscribeCalendarConfirmation. + /// + /// In en, this message translates to: + /// **'This removes the local subscription and its cached events. The published calendar is not changed.'** + String get unsubscribeCalendarConfirmation; + + /// No description provided for @addSubscriptionAction. + /// + /// In en, this message translates to: + /// **'Add subscription'** + String get addSubscriptionAction; + + /// No description provided for @subscriptionOperationFailed. + /// + /// In en, this message translates to: + /// **'Calendar subscription failed: {error}'** + String subscriptionOperationFailed(String error); + + /// No description provided for @subscriptions. + /// + /// In en, this message translates to: + /// **'Subscriptions'** + String get subscriptions; + + /// No description provided for @calendarImport. + /// + /// In en, this message translates to: + /// **'Calendar import'** + String get calendarImport; + + /// No description provided for @calendarImportDescription. + /// + /// In en, this message translates to: + /// **'Select a file, review its events, then choose the writable calendar that should receive them.'** + String get calendarImportDescription; + + /// No description provided for @importIcsFile. + /// + /// In en, this message translates to: + /// **'Import .ics file'** + String get importIcsFile; + + /// No description provided for @importIcsPreview. + /// + /// In en, this message translates to: + /// **'Import calendar events'** + String get importIcsPreview; + + /// No description provided for @importEventsFound. + /// + /// In en, this message translates to: + /// **'Importable event sets: {count}'** + String importEventsFound(int count); + + /// No description provided for @importInvalidEvents. + /// + /// In en, this message translates to: + /// **'Invalid events: {count}'** + String importInvalidEvents(int count); + + /// No description provided for @importFieldsOmitted. + /// + /// In en, this message translates to: + /// **'Intentionally omitted: {fields}'** + String importFieldsOmitted(String fields); + + /// No description provided for @noWritableCalendars. + /// + /// In en, this message translates to: + /// **'No writable destination calendar is available.'** + String get noWritableCalendars; + + /// No description provided for @importDestinationCalendar. + /// + /// In en, this message translates to: + /// **'Destination calendar'** + String get importDestinationCalendar; + + /// No description provided for @importIcsConfirm. + /// + /// In en, this message translates to: + /// **'Import events'** + String get importIcsConfirm; + + /// No description provided for @importIcsComplete. + /// + /// In en, this message translates to: + /// **'Import complete'** + String get importIcsComplete; + + /// No description provided for @importQueued. + /// + /// In en, this message translates to: + /// **'Imported or queued: {count}'** + String importQueued(int count); + + /// No description provided for @importDuplicatesSkipped. + /// + /// In en, this message translates to: + /// **'Duplicates skipped: {count}'** + String importDuplicatesSkipped(int count); + + /// No description provided for @importUnsupportedSets. + /// + /// In en, this message translates to: + /// **'Unsupported recurrence sets: {count}'** + String importUnsupportedSets(int count); + + /// No description provided for @importIcsFailed. + /// + /// In en, this message translates to: + /// **'Could not import the calendar file: {error}'** + String importIcsFailed(String error); + /// No description provided for @networkOffline. /// /// In en, this message translates to: @@ -3563,6 +4263,140 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'You’re offline. Connect to the internet and try again.'** String get networkOfflineTryAgain; + + /// Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers. + /// + /// In en, this message translates to: + /// **'on days {days}'** + String repeatOnMonthDaysSummaryMultiple(String days); + + /// Separator inserted between the base recurrence frequency and a following day or ordinal phrase. + /// + /// In en, this message translates to: + /// **' '** + String get repeatSummarySeparator; + + /// Localized day-of-month value used when composing a list of recurrence days. + /// + /// In en, this message translates to: + /// **'{day}'** + String repeatMonthDayValue(String day); + + /// Joins the final two weekday names in a recurrence summary. + /// + /// In en, this message translates to: + /// **'{first} and {second}'** + String repeatWeekdayListPair(String first, String second); + + /// Prepends a weekday name to an already localized recurrence weekday list. + /// + /// In en, this message translates to: + /// **'{first}, {rest}'** + String repeatWeekdayListStart(String first, String rest); + + /// Joins the final two day-of-month values in a monthly recurrence summary. + /// + /// In en, this message translates to: + /// **'{first} and {second}'** + String repeatMonthDayListPair(String first, String second); + + /// Prepends a day-of-month value to an already localized monthly recurrence day list. + /// + /// In en, this message translates to: + /// **'{first}, {rest}'** + String repeatMonthDayListStart(String first, String rest); + + /// Month value used in yearly recurrence sentences. Locales may select an inflected form with {monthKey}; {month} is the CLDR abbreviated month fallback. + /// + /// In en, this message translates to: + /// **'{monthKey, select, other{{month}}}'** + String repeatYearlyMonthValue(String month, String monthKey); + + /// Joins the final two localized day-of-month values in a yearly recurrence summary. + /// + /// In en, this message translates to: + /// **'{first} and {second}'** + String repeatYearlyMonthDayListPair(String first, String second); + + /// Prepends a localized day-of-month value to an already localized yearly recurrence day list. + /// + /// In en, this message translates to: + /// **'{first}, {rest}'** + String repeatYearlyMonthDayListStart(String first, String rest); + + /// Joins the final two localized month names in a yearly recurrence summary. + /// + /// In en, this message translates to: + /// **'{first} and {second}'** + String repeatYearlyMonthListPair(String first, String second); + + /// Prepends a localized month name to an already localized yearly recurrence month list. + /// + /// In en, this message translates to: + /// **'{first}, {rest}'** + String repeatYearlyMonthListStart(String first, String rest); + + /// Complete yearly recurrence summary for one day in one month. + /// + /// In en, this message translates to: + /// **'{frequency} on {month} {day}'** + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ); + + /// Complete yearly recurrence summary for multiple days in one month. + /// + /// In en, this message translates to: + /// **'{frequency} on days {days} of {month}'** + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ); + + /// Complete yearly recurrence summary for one day in multiple months. + /// + /// In en, this message translates to: + /// **'{frequency} on day {day} of {months}'** + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ); + + /// Complete yearly recurrence summary for multiple days in multiple months. + /// + /// In en, this message translates to: + /// **'{frequency} on days {days} of {months}'** + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ); + + /// Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale. + /// + /// In en, this message translates to: + /// **'{frequency} {position, select, first{on the first {days} of {month}} second{on the second {days} of {month}} third{on the third {days} of {month}} fourth{on the fourth {days} of {month}} fifth{on the fifth {days} of {month}} secondToLast{on the second to last {days} of {month}} last{on the last {days} of {month}} other{on {days} of {month}}}'** + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ); + + /// Complete yearly ordinal-weekday recurrence summary for multiple months. + /// + /// In en, this message translates to: + /// **'{frequency} {position, select, first{on the first {days} of {months}} second{on the second {days} of {months}} third{on the third {days} of {months}} fourth{on the fourth {days} of {months}} fifth{on the fifth {days} of {months}} secondToLast{on the second to last {days} of {months}} last{on the last {days} of {months}} other{on {days} of {months}}}'** + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ); } class _AppLocalizationsDelegate @@ -3613,6 +4447,18 @@ AppLocalizations lookupAppLocalizations(Locale locale) { } } + // Lookup logic when language+country codes are specified. + switch (locale.languageCode) { + case 'pt': + { + switch (locale.countryCode) { + case 'PT': + return AppLocalizationsPtPt(); + } + break; + } + } + // Lookup logic when only language code is specified. switch (locale.languageCode) { case 'ar': diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 93a7b41b..e1014fd1 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -12,12 +12,109 @@ import 'app_localizations.dart'; class AppLocalizationsAr extends AppLocalizations { AppLocalizationsAr([String locale = 'ar']) : super(locale); + @override + String get windowsSupport => 'الدعم'; + + @override + String get windowsThirdPartyLicenses => 'تراخيص الجهات الخارجية'; + + @override + String get windowsSearch => 'بحث'; + + @override + String get windowsStartupDisabledByUser => + 'عطّل المستخدم هذه الميزة في إعدادات Windows.'; + + @override + String get windowsStartupDisabledByPolicy => 'معطّلة بواسطة نهج Windows.'; + + @override + String get windowsStartupUnavailable => + 'تتوفر بعد تثبيت BusyMax من حزمة MSIX.'; + + @override + String get windowsReminderExitNotice => + 'تتوقف التذكيرات عند إنهاء BusyMax بالكامل. أبقِه قيد التشغيل في الخلفية لتلقيها.'; + + @override + String get windowsProductVersionLabel => 'إصدار المنتج'; + + @override + String get windowsPackageVersionLabel => 'إصدار حزمة Windows'; + + @override + String get windowsUnpackaged => 'غير معبأ'; + + @override + String get windowsAgendaLoadMore => 'تحميل المزيد من عناصر جدول الأعمال'; + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'الاثنين', + 'TU': 'الثلاثاء', + 'WE': 'الأربعاء', + 'TH': 'الخميس', + 'FR': 'الجمعة', + 'SA': 'السبت', + 'SU': 'الأحد', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'في يومي $first و$second من الشهر'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency في يومي $firstDay و$secondDay من $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency في اليوم $day من شهري $firstMonth و$secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency في يومي $firstDay و$secondDay من شهري $firstMonth و$secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency في الأيام $days من شهري $firstMonth و$secondMonth'; + } + @override String get appTitle => 'BusyMax'; @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'اربط حسابات Google أو Microsoft أو تقويم Apple iCloud أو Nextcloud.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +138,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'أضف كل الحسابات التي تريد استخدامها. يزامن BusyMax التقويمات والأحداث وقوائم المهام والمهام المدعومة من كل حساب.'; @override String get onboardingPreferencesStepTitle => 'اختيار إعدادات النظام'; @@ -118,6 +215,43 @@ class AppLocalizationsAr extends AppLocalizations { @override String get calendars => 'التقويمات'; + @override + String get newCalendar => 'تقويم جديد'; + + @override + String get calendarColor => 'لون التقويم'; + + @override + String calendarColorOption(int number) { + return 'اللون $number'; + } + + @override + String get calendarManagementUnsupported => + 'لا يدعم هذا المزوّد إدارة التقويمات في BusyMax.'; + + @override + String get primaryCalendarCannotDelete => 'لا يمكن حذف التقويم الأساسي.'; + + @override + String calendarCreateFailed(String error) { + return 'تعذّر إنشاء التقويم: ⁨$error⁩'; + } + + @override + String get calendarCreatedRefreshPending => + 'تم إنشاء التقويم، لكن تعذّر على BusyMax تحديث الحساب. سيظهر بعد المزامنة التالية.'; + + @override + String calendarUpdateFailed(String error) { + return 'تعذّر تحديث التقويم: ⁨$error⁩'; + } + + @override + String calendarDeleteFailed(String error) { + return 'تعذّر حذف التقويم: ⁨$error⁩'; + } + @override String get newEvent => 'حدث جديد'; @@ -141,7 +275,20 @@ class AppLocalizationsAr extends AppLocalizations { @override String moreItems(int count) { - return '+⁨$count⁩ عناصر أخرى'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '+⁨$countString⁩ عنصر آخر', + many: '+⁨$countString⁩ عنصرًا آخر', + few: '+⁨$countString⁩ عناصر أخرى', + two: '+عنصران آخران', + one: '+عنصر واحد آخر', + ); + return '$_temp0'; } @override @@ -180,6 +327,114 @@ class AppLocalizationsAr extends AppLocalizations { @override String get trayOpenBusyMax => 'فتح BusyMax'; + @override + String get trayShowBusyMax => 'إظهار BusyMax'; + + @override + String get trayNewEvent => 'حدث جديد…'; + + @override + String get trayNewTask => 'مهمة جديدة…'; + + @override + String get trayToday => 'اليوم'; + + @override + String get trayAllDay => 'طوال اليوم'; + + @override + String get trayNow => 'الآن'; + + @override + String get trayCalendarEvent => 'حدث في التقويم'; + + @override + String get trayUntitledEvent => 'حدث بلا عنوان'; + + @override + String get trayNothingElseToday => 'لا شيء آخر اليوم'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '⁨$count⁩ مهمة مستحقة اليوم', + many: '⁨$count⁩ مهمة مستحقة اليوم', + few: '⁨$count⁩ مهام مستحقة اليوم', + two: 'مهمتان مستحقتان اليوم', + one: 'مهمة واحدة مستحقة اليوم', + zero: 'لا توجد مهام مستحقة اليوم', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'فتح جدول أعمال اليوم'; + + @override + String get traySyncNow => 'مزامنة الآن'; + + @override + String get traySyncing => 'جارٍ العمل على المزامنة…'; + + @override + String get trayNotConnected => 'غير متصل'; + + @override + String get trayNotYetSynced => 'لم تتم المزامنة بعد'; + + @override + String get trayLastSyncedJustNow => 'تمت المزامنة للتو'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'تمت المزامنة قبل ⁨$count⁩ دقيقة', + many: 'تمت المزامنة قبل ⁨$count⁩ دقيقة', + few: 'تمت المزامنة قبل ⁨$count⁩ دقائق', + two: 'تمت المزامنة قبل دقيقتين', + one: 'تمت المزامنة قبل دقيقة واحدة', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'تمت المزامنة قبل ⁨$count⁩ ساعة', + many: 'تمت المزامنة قبل ⁨$count⁩ ساعة', + few: 'تمت المزامنة قبل ⁨$count⁩ ساعات', + two: 'تمت المزامنة قبل ساعتين', + one: 'تمت المزامنة قبل ساعة واحدة', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'تمت المزامنة قبل ⁨$count⁩ يوم', + many: 'تمت المزامنة قبل ⁨$count⁩ يومًا', + few: 'تمت المزامنة قبل ⁨$count⁩ أيام', + two: 'تمت المزامنة قبل يومين', + one: 'تمت المزامنة قبل يوم واحد', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'الإعدادات'; + + @override + String get trayQuitBusyMax => 'إنهاء BusyMax'; + @override String get agendaLoadMoreOverdue => 'تحميل المزيد من المهام المتأخرة'; @@ -263,127 +518,126 @@ class AppLocalizationsAr extends AppLocalizations { String get noGuests => 'لا يوجد مدعوون'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'مطلوب'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'اختياري'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'الاجتماع'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'إضافة Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'إضافة اجتماع Microsoft Teams'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'تمت إضافة الاجتماع عبر الإنترنت'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'طلب الردود'; @override - String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + String get requestResponsesDescription => 'اطلب من المدعوين الرد على الدعوة.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'إخفاء قائمة المدعوين'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'لا يمكن للمدعوين رؤية المدعوين الآخرين.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'السماح باقتراح أوقات جديدة'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'يمكن للمدعوين اقتراح وقت مختلف للاجتماع.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'إبلاغ المدعوين؟'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'يضم هذا الاجتماع مدعوين. هل تريد إرسال الدعوات أو تحديثات الحدث عند حفظه؟'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'يضم هذا الاجتماع مدعوين. هل تريد إرسال إلغاء عند حذفه؟'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'إرسال التحديثات'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'إرسال الإلغاء'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'عدم الإرسال'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'حفظ الاجتماع؟'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'سترسل Microsoft الدعوات أو تحديثات الحدث إلى المدعوين.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'حذف الاجتماع؟'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'سترسل Microsoft إلغاءً إلى المدعوين.'; @override - String get organizer => 'Organizer'; + String get organizer => 'المنظّم'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'ردك'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'ردود المدعوين'; @override - String get respond => 'Respond'; + String get respond => 'الرد'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'قبول'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'مبدئي'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'رفض'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'الانضمام إلى الاجتماع'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'مقبول'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'مبدئي'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'مرفوض'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'بانتظار الرد'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'لم يتم الرد'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'المنظّم'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'تعذّر إرسال ردك: ⁨$error⁩'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'تعذّر فتح رابط الاجتماع.'; @override String get description => 'الوصف'; @@ -429,12 +683,16 @@ class AppLocalizationsAr extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: 'قبل ⁨$minutes⁩ دقيقة', - many: 'قبل ⁨$minutes⁩ دقيقة', - few: 'قبل ⁨$minutes⁩ دقائق', + other: 'قبل ⁨$minutesString⁩ دقيقة', + many: 'قبل ⁨$minutesString⁩ دقيقة', + few: 'قبل ⁨$minutesString⁩ دقائق', two: 'قبل دقيقتين', one: 'قبل دقيقة واحدة', zero: 'عند البدء', @@ -447,12 +705,16 @@ class AppLocalizationsAr extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: 'قبل ⁨$hours⁩ ساعة', - many: 'قبل ⁨$hours⁩ ساعة', - few: 'قبل ⁨$hours⁩ ساعات', + other: 'قبل ⁨$hoursString⁩ ساعة', + many: 'قبل ⁨$hoursString⁩ ساعة', + few: 'قبل ⁨$hoursString⁩ ساعات', two: 'قبل ساعتين', one: 'قبل ساعة واحدة', zero: 'عند البدء', @@ -462,12 +724,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: 'قبل ⁨$days⁩ يوم', - many: 'قبل ⁨$days⁩ يومًا', - few: 'قبل ⁨$days⁩ أيام', + other: 'قبل ⁨$daysString⁩ يوم', + many: 'قبل ⁨$daysString⁩ يومًا', + few: 'قبل ⁨$daysString⁩ أيام', two: 'قبل يومين', one: 'قبل يوم واحد', zero: 'في اليوم نفسه', @@ -513,7 +780,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String tasksInList(String title) { - return 'المهام في ⁨$title⁩'; + return 'المهام في ⁨⁨$title⁩⁩'; } @override @@ -655,7 +922,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'يضيف فقط إصدار نظام التشغيل Linux ولغة التطبيق ومنطقته. لا يتم تضمين أي سجلات أو بيانات حسابات أو أسماء ملفات أو معلومات تشخيصية أخرى.'; + 'يضيف فقط اسم نظام التشغيل وإصداره ولغة التطبيق ومنطقته. لا يتم تضمين أي سجلات أو بيانات حسابات أو أسماء ملفات أو معلومات تشخيصية أخرى.'; @override String get feedbackCategoryRequired => 'اختر فئة.'; @@ -693,7 +960,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String feedbackSuccess(String id) { - return 'تم إرسال الملاحظات. المرجع: ⁨$id⁩'; + return 'تم إرسال الملاحظات. المرجع: ⁨⁨$id⁩⁩'; } @override @@ -741,12 +1008,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String removeAccountTitle(String account) { - return 'إزالة ⁨$account⁩ من BusyMax؟'; + return 'إزالة ⁨⁨$account⁩⁩ من BusyMax؟'; } @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'سيؤدي هذا إلى حذف المهام والتقويمات والأحداث والتذكيرات والتغييرات غير المتصلة المخزنة مؤقتًا من هذا الجهاز. ستُفقد التغييرات التي لم تتم مزامنتها، ولن تُحذف نسخ التقويمات والأحداث وقوائم المهام والمهام لدى موفّر الخدمة.'; @override String get revokeGoogleAccess => @@ -771,17 +1038,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'تعذّر إنشاء قائمة المهام: ⁨$error⁩'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'تعذّر إعادة تسمية قائمة المهام: ⁨$error⁩'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'تعذّر حذف قائمة المهام: ⁨$error⁩'; } @override @@ -810,11 +1077,11 @@ class AppLocalizationsAr extends AppLocalizations { @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'قائمة المهام هذه للقراءة فقط ولا يمكن إعادة تسميتها.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'لا يمكن حذف قائمة المهام هذه باستخدام أذوناتك الحالية.'; @override String get builtInMicrosoftList => 'مدمجة'; @@ -825,17 +1092,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String deleteListConfirmation(String title) { - return 'حذف «⁨$title⁩» من Google Tasks؟'; + return 'حذف \"⁨⁨$title⁩⁩\" من Google Tasks؟'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return 'حذف \"⁨$title⁩\" وجميع مهامها؟'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'إلغاء مشاركة \"⁨$title⁩\" من هذا الحساب؟'; } @override @@ -867,17 +1134,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String exportedFile(String path) { - return 'تم التصدير إلى ⁨$path⁩'; + return 'تم التصدير إلى ⁨⁨$path⁩⁩'; } @override String exportFailed(String error) { - return 'فشل التصدير: ⁨$error⁩'; + return 'فشل التصدير: ⁨⁨$error⁩⁩'; } @override String refreshFailed(String error) { - return 'فشل التحديث: ⁨$error⁩'; + return 'فشل التحديث: ⁨⁨$error⁩⁩'; } @override @@ -918,12 +1185,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String duePrefix(String date) { - return 'مستحقة في ⁨$date⁩'; + return 'مستحقة في ⁨⁨$date⁩⁩'; } @override String dateTimeDisplay(String date, String time) { - return '⁨$date⁩ · ⁨$time⁩'; + return '⁨⁨$date⁩⁩ · ⁨⁨$time⁩⁩'; } @override @@ -957,75 +1224,91 @@ class AppLocalizationsAr extends AppLocalizations { String get openStatus => 'مفتوحة'; @override - String get doneStatus => 'منجزة'; + String get doneStatus => 'منجز'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'الحالة'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'بلا حالة'; @override String get taskStatusNeedsAction => 'تحتاج إلى إجراء'; @override - String get taskStatusInProcess => 'تحت الإجراء'; + String get taskStatusInProcess => 'قيد التنفيذ'; @override - String get taskStatusCompleted => 'مُكتمِل'; + String get taskStatusCompleted => 'مكتملة'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'ملغاة'; @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return 'اكتمل بنسبة $percentString٪'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'تاريخ الإكمال'; @override String get priority => 'الأولوية'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'بلا أولوية'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'الأولوية $priorityString · عالية'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'الأولوية $priorityString · متوسطة'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'الأولوية $priorityString · منخفضة'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'URL المهمة'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'أدخل عنوان URL مطلقًا يتضمن مخططه.'; @override - String get classification => 'Classification'; + String get classification => 'التصنيف'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => 'عند المشاركة، أظهر المهمة كاملة'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => 'عند المشاركة، أظهر الانشغال فقط'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'عند المشاركة، أخفِ هذه المهمة'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'تثبيت المهمة'; @override String get notes => 'ملاحظات'; @@ -1064,82 +1347,86 @@ class AppLocalizationsAr extends AppLocalizations { String get addReminder => 'إضافة تذكير'; @override - String get reminders => 'Reminders'; + String get reminders => 'التذكيرات'; @override - String get noReminders => 'لا توجد أي تذكيرات'; + String get noReminders => 'لا توجد تذكيرات'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'تعديل التذكير'; @override - String get beforeTaskStarts => 'قبل أن تبدأ المهمة'; + String get beforeTaskStarts => 'قبل بدء المهمة'; @override - String get beforeTaskDue => 'قبل اكتمال المهمة'; + String get beforeTaskDue => 'قبل موعد استحقاق المهمة'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'بعد بدء المهمة'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'بعد استحقاق المهمة'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'بالنسبة إلى تاريخ بدء المهمة'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'بالنسبة إلى تاريخ استحقاق المهمة'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'وقت اليوم'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'في تاريخ ووقت'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'الكمية'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'الوحدة'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'ثوانٍ'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'دقائق'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'ساعات'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'أيام'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'أسابيع'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'عند بدء المهمة'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'عند وقت استحقاق المهمة'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'يُحتفظ بنوع هذا التذكير، لكن لا يمكن تعديل وقته.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'الاحتفاظ بالتذكيرات المرتبطة؟'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'يحتوي هذا التاريخ على $countString من التذكيرات المرتبطة. هل تريد الاحتفاظ بها في تاريخها ووقتها الحاليين؟'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => 'تجاهل التذكيرات'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => 'الاحتفاظ بالتذكيرات'; @override String get addGuest => 'إضافة مدعو'; @@ -1175,121 +1462,219 @@ class AppLocalizationsAr extends AppLocalizations { String get repeatYearly => 'سنويًا'; @override - String get repeatEvery => 'تكرار كل'; + String get repeatEvery => 'الفاصل الزمني'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'التكرار في'; @override - String get repeatEnd => 'نهاية التكرار'; + String get repeatEnd => 'إنهاء التكرار'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'مطلقًا'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'في تاريخ'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'بعد عدد من التكرارات'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'عدد التكرارات'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'أيام الشهر'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'الأشهر'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'ترتيب يوم الأسبوع'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'أيام محددة'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'الأول'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'الثاني'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'الثالث'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'الرابع'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'الخامس'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'ما قبل الأخير'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'الأخير'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'اليوم'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'يوم من أيام الأسبوع'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'يوم عطلة نهاية الأسبوع'; + + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'اثنين', + 'TU': 'ثلاثاء', + 'WE': 'أربعاء', + 'TH': 'خميس', + 'FR': 'جمعة', + 'SA': 'سبت', + 'SU': 'أحد', + 'day': 'يوم', + 'weekday': 'يوم من أيام الأسبوع', + 'weekend': 'يوم من عطلة نهاية الأسبوع', + 'other': '$day', + }); + return '$_temp0'; + } @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'كل $countString يوم', + many: 'كل $countString يومًا', + few: 'كل $countString أيام', + two: 'كل يومين', + one: 'كل يوم', + ); + return '$_temp0'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'كل $countString أسبوع', + many: 'كل $countString أسبوعًا', + few: 'كل $countString أسابيع', + two: 'كل أسبوعين', + one: 'كل أسبوع', + ); + return '$_temp0'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'كل $countString شهر', + many: 'كل $countString شهرًا', + few: 'كل $countString أشهر', + two: 'كل شهرين', + one: 'كل شهر', + ); + return '$_temp0'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'كل $countString سنة', + many: 'كل $countString سنة', + few: 'كل $countString سنوات', + two: 'كل سنتين', + one: 'كل سنة', + ); + return '$_temp0'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return 'في $days'; } @override String repeatOnMonthDaysSummary(String days) { - return 'في يوم $days'; + return 'في اليوم $days من الشهر'; } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'في أول $days', + 'second': 'في ثاني $days', + 'third': 'في ثالث $days', + 'fourth': 'في رابع $days', + 'fifth': 'في خامس $days', + 'secondToLast': 'في $days قبل الأخير', + 'last': 'في آخر $days', + 'other': 'في $days', + }); + return '$_temp0'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return 'في $months'; } @override String repeatTimesSummary(int count) { - return '$count مرات'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString مرة', + many: '$countString مرة', + few: '$countString مرات', + two: 'مرتين', + one: 'مرة واحدة', + ); + return '$_temp0'; } @override String repeatUntilSummary(String date) { - return 'until $date'; + return 'حتى ⁨$date⁩'; } @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'تستخدم قاعدة التكرار هذه خيارات لا يغيّرها هذا المحرر.'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'لا يمكن استخدام هذا التكرار مع ⁨$provider⁩.'; + } @override String get importance => 'الأهمية'; @@ -1344,21 +1729,21 @@ class AppLocalizationsAr extends AppLocalizations { String get subtasks => 'مهام فرعية'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'تكرار المهمة'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'تم تكرار المهمة.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'تعذّر تكرار المهمة: ⁨$error⁩'; } @override - String get hideSubtasks => 'إخْفِ المهام الفرعية'; + String get hideSubtasks => 'إخفاء المهام الفرعية'; @override - String get hideClosedSubtasks => 'إخف المهام الفرعية المغلقة'; + String get hideClosedSubtasks => 'إخفاء المهام الفرعية المغلقة'; @override String get moveToTop => 'نقل إلى الأعلى'; @@ -1371,7 +1756,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String deleteTaskConfirmation(String title) { - return 'حذف «⁨$title⁩»؟'; + return 'حذف «⁨⁨$title⁩⁩»؟'; } @override @@ -1414,7 +1799,11 @@ class AppLocalizationsAr extends AppLocalizations { String get sync => 'المزامنة'; @override - String get manualFullSync => 'مزامنة كاملة يدويًا'; + String get forceFullResync => 'فرض إعادة مزامنة كاملة'; + + @override + String get forceFullResyncDescription => + 'إعادة تحميل جميع البيانات بالكامل من كل حساب متصل. استخدم هذا الخيار فقط لاستكشاف مشكلات المزامنة وإصلاحها.'; @override String get runInBackgroundWhenClosed => 'متابعة التشغيل عند إغلاق النافذة'; @@ -1444,7 +1833,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String syncFailed(String error) { - return 'فشلت المزامنة: ⁨$error⁩'; + return 'فشلت المزامنة: ⁨⁨$error⁩⁩'; } @override @@ -1459,6 +1848,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get eventReminders => 'تذكيرات الأحداث'; + @override + String get onState => 'تشغيل'; + @override String get taskReminders => 'تذكيرات المهام'; @@ -1486,6 +1878,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String get notifications => 'الإشعارات'; + @override + String get windowsNotificationsUnavailable => 'إشعارات Windows غير متاحة'; + + @override + String get windowsNotificationsUnpackaged => + 'لا يمكن لتشغيل التطوير غير المحزّم هذا استخدام إشعارات Windows. ثبّت حزمة MSIX الموقّعة للاختبار لاختبار التذكيرات.'; + + @override + String get windowsNotificationsInstalledFailure => + 'تعذّر على BusyMax تهيئة إشعارات Windows. لن تظهر التذكيرات حتى تُحل مشكلة التثبيت هذه.'; + @override String get appearance => 'المظهر'; @@ -1495,6 +1898,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get themeSystem => 'النظام'; + @override + String get settingsSystem => 'النظام'; + @override String get themeLight => 'فاتحة'; @@ -1534,7 +1940,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String discoveryRevision(String revision) { - return 'مراجعة Discovery: ⁨$revision⁩'; + return 'مراجعة Discovery: ⁨⁨$revision⁩⁩'; } @override @@ -1561,17 +1967,21 @@ class AppLocalizationsAr extends AppLocalizations { @override String pendingOpListId(String id) { - return 'القائمة=⁨$id⁩'; + return 'القائمة=⁨⁨$id⁩⁩'; } @override String pendingOpTaskId(String id) { - return 'المهمة=⁨$id⁩'; + return 'المهمة=⁨⁨$id⁩⁩'; } @override String pendingOpAttempts(int count) { - return 'المحاولات=⁨$count⁩'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'المحاولات=⁨$countString⁩'; } @override @@ -1608,7 +2018,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String syncFailureNotificationBody(String message) { - return 'فشلت المزامنة في الخلفية. ⁨$message⁩'; + return 'فشلت المزامنة في الخلفية. ⁨⁨$message⁩⁩'; } @override @@ -1616,7 +2026,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return 'تم حظر تغيير محلي معلّق. ⁨$summary⁩'; + return 'تم حظر تغيير محلي معلق. ⁨⁨$summary⁩⁩'; } @override @@ -1624,12 +2034,16 @@ class AppLocalizationsAr extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'هناك ⁨$count⁩ مهمة مستحقة اليوم.', - many: 'هناك ⁨$count⁩ مهمة مستحقة اليوم.', - few: 'هناك ⁨$count⁩ مهام مستحقة اليوم.', + other: 'هناك ⁨$countString⁩ مهمة مستحقة اليوم.', + many: 'هناك ⁨$countString⁩ مهمة مستحقة اليوم.', + few: 'هناك ⁨$countString⁩ مهام مستحقة اليوم.', two: 'هناك مهمتان مستحقتان اليوم.', one: 'هناك مهمة واحدة مستحقة اليوم.', zero: 'لا توجد مهام مستحقة اليوم.', @@ -1656,7 +2070,7 @@ class AppLocalizationsAr extends AppLocalizations { String get notificationSnoozeAction => 'غفوة لمدة 10 دقائق'; @override - String get notificationDismissAction => 'تجاهل'; + String get notificationDismissAction => 'إغلاق'; @override String get notificationDetailsHidden => @@ -1682,7 +2096,11 @@ class AppLocalizationsAr extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'الأسبوع ⁨$number⁩'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'الأسبوع ⁨$numberString⁩'; } @override @@ -1690,12 +2108,16 @@ class AppLocalizationsAr extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '⁨$count⁩ عنصر', - many: '⁨$count⁩ عنصرًا', - few: '⁨$count⁩ عناصر', + other: '⁨$countString⁩ عنصر', + many: '⁨$countString⁩ عنصرًا', + few: '⁨$countString⁩ عناصر', two: 'عنصران', one: 'عنصر واحد', zero: 'لا عناصر', @@ -1710,20 +2132,20 @@ class AppLocalizationsAr extends AppLocalizations { String get selectTimeZone => 'اختيار المنطقة الزمنية'; @override - String get searchLocations => 'البحث عن مواقع'; + String get searchLocations => 'البحث عن المواقع'; @override String get noLocationsFound => 'لم يتم العثور على مواقع'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'هذا الحقل مطلوب.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'اربط التقويمات والمهام من أحد موفّري الخدمة هؤلاء.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'تقويم Apple iCloud'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1732,198 +2154,219 @@ class AppLocalizationsAr extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'مهام Nextcloud'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'إضافة حساب تقويم Apple iCloud'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'إضافة حساب Nextcloud'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'جارٍ الاتصال بـ Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'بانتظار تفويض Nextcloud…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'ربط تقويم Apple iCloud'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'البريد الإلكتروني لحساب Apple'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'كلمة مرور خاصة بالتطبيق'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'أنشئ كلمة مرور خاصة بالتطبيق بعد تفعيل المصادقة الثنائية لحساب Apple.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'تؤدي إعادة تعيين كلمة مرور حساب Apple إلى إبطال كلمات المرور الخاصة بالتطبيقات.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'ربط Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'خادم Nextcloud أو عنوان CalDAV'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'أدخل عنوان URL لخادم Nextcloud أو الصق عنوان CalDAV الأساسي المنسوخ من Nextcloud.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'سيفتح BusyMax متصفحك. وافق على الوصول هناك، ثم عد إلى BusyMax.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'ربط'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'إلغاء الربط'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'تمت إزالة الحساب محليًا، لكن تعذّر إبطال كلمة مرور تطبيق Nextcloud.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'تُخزّن بيانات التقويم والمهام محليًا للاستخدام دون اتصال.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'أعد ربط هذا الحساب لاستئناف المزامنة.'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get davTemporarilyUnavailable => 'هذا الحساب غير متاح مؤقتًا.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'تغيّرت أذونات الخادم. تم إيقاف التعديلات المعلقة مؤقتًا.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'هذا الخادم أو ملف موفّر الخدمة غير مدعوم.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'التقويمات وقوائم المهام'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'أحداث التقويم'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'المهام'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'للقراءة فقط'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'معلق محليًا'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'محظور بسبب تعارض'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'محظور حتى إعادة الاتصال'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'فشلت العملية'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'الاحتفاظ بإصدار الخادم'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'مراجعة التغيير المحلي وإعادة تطبيقه'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'تكرار كعنصر جديد'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'حالة الاتصال'; @override - String get davConnected => 'Connected'; + String get davConnected => 'متصل'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'جارٍ الاتصال…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'تم تسجيل الخروج'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'آخر مزامنة ناجحة: ⁨$time⁩'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'لم تتم المزامنة بعد'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'تحديث التقويمات وقوائم المهام'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'الخادم: ⁨$host⁩'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'تقويم أحداث'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'قائمة مهام'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'الأحداث والمهام'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'قابل للكتابة'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'مشترك'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'آخر مزامنة: ⁨$time⁩'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'مشكلة في المزامنة: ⁨$code⁩'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'تعارضات المزامنة'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'تم التغيير على الخادم في: ⁨⁨$time⁩⁩'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'تعديل محلي: ⁨$summary⁩'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'تعذّر حل التعارض.'; + + @override + String get recurringEventScope => 'نطاق الحدث المتكرر'; + + @override + String get entireSeries => 'السلسلة بأكملها'; + + @override + String get singleOccurrence => 'هذا الحدث'; @override - String get recurringEventScope => 'Recurring event scope'; + String get thisAndFollowingEvents => 'هذا الحدث والأحداث التالية'; @override - String get entireSeries => 'Entire series'; + String get thisAndFutureUnavailable => 'غير مدعوم من موفّر الخدمة هذا.'; @override - String get singleOccurrence => 'This occurrence'; + String get thisAndFutureMoveUnavailable => + 'لا يمكن نقل هذا الحدث والأحداث التالية بأمان. اختر هذا الحدث أو السلسلة كاملة.'; @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get entireSeriesMoveUnavailable => + 'قاعدة التكرار غير متوفرة محليًا. انقل هذا الحدث بدلاً من ذلك.'; + + @override + String get copyEventAndDeleteOriginal => 'هل تريد نسخ الحدث وحذف الأصل؟'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'لا يستطيع BusyMax نقل هذا الحدث مباشرةً من ⁨$source⁩ إلى ⁨$destination⁩. سينشئ النسخة أولاً ولن يحذف الأصل إلا بعد نجاح النسخ. ستتغير معرّفات الحدث؛ وقد تُعاد تعيين حالات استجابة الحاضرين وتُرسل دعوات أو إلغاءات؛ وقد لا تُنقل روابط الاجتماعات والمرفقات والتذكيرات والحقول الخاصة بموفّر الخدمة واستثناءات التكرار.'; + } + + @override + String get copyAndDelete => 'نسخ وحذف'; @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'اختر ما إذا كان هذا التغيير ينطبق على السلسلة بأكملها، أو هذا الحدث فقط، أو هذا الحدث والأحداث التالية.'; @override String get taskDueBeforeStart => 'يجب ألا يكون موعد الاستحقاق قبل وقت البدء.'; @@ -1934,7 +2377,200 @@ class AppLocalizationsAr extends AppLocalizations { @override String deleteCalendarConfirmation(String title) { - return 'حذف «⁨$title⁩»؟'; + return 'حذف «⁨⁨$title⁩⁩»؟'; + } + + @override + String get setCustomCalendarName => 'تعيين اسم مخصص'; + + @override + String get setAction => 'تعيين'; + + @override + String get removeFromMyCalendars => 'إزالة من تقاويمي'; + + @override + String get removeAction => 'إزالة'; + + @override + String removeCalendarConfirmation(String title) { + return 'هل تريد إزالة \"⁨$title⁩\" من قائمة تقويم Google؟ لن يتم حذف التقويم المشترك أو أحداثه.'; + } + + @override + String get calendarCannotRemove => + 'لا يمكن حذف هذا التقويم أو إزالته من هذا الحساب.'; + + @override + String get calendarPendingChangesPreventRemoval => + 'انتظر حتى تنتهي مزامنة التغييرات المعلقة لهذا التقويم قبل حذفه أو إزالته.'; + + @override + String get calendarSubscriptions => 'اشتراكات التقويم'; + + @override + String get calendarSubscriptionsDescription => + 'أضف تقاويم للقراءة فقط يتم تحديثها من عنوان WebCal آمن.'; + + @override + String get addCalendarSubscription => 'إضافة اشتراك تقويم'; + + @override + String get subscriptionName => 'الاسم المحلي'; + + @override + String get subscriptionUrl => 'عنوان URL للاشتراك'; + + @override + String get subscriptionUrlHelp => + 'أدخل عنوان HTTPS أو webcal. يحتفظ BusyMax بعنوان URL الكامل في التخزين الآمن.'; + + @override + String get subscriptionUrlInvalid => + 'أدخل عنوان HTTPS أو webcal صالحًا من دون معلومات مستخدم أو جزء.'; + + @override + String get subscriptionColor => 'اللون المحلي'; + + @override + String get subscriptionColorHelp => 'استخدم لونًا من ستة أرقام مثل #3584E4.'; + + @override + String get subscriptionColorInvalid => + 'أدخل لونًا سداسيًا عشريًا من ستة أرقام.'; + + @override + String get subscriptionRefreshMode => 'تكرار التحديث'; + + @override + String get subscriptionAutomatic => 'تلقائي'; + + @override + String get subscriptionHourly => 'كل ساعة'; + + @override + String get subscriptionSixHours => 'كل ست ساعات'; + + @override + String get subscriptionDaily => 'يوميًا'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'المصدر: ⁨$origin⁩'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'أدخل عنوان URL صالحًا لمعاينة مصدره الآمن.'; + + @override + String get subscriptionReadOnly => 'اشتراك للقراءة فقط'; + + @override + String get subscriptionNeverRefreshed => 'لم يتم التحديث بعد'; + + @override + String subscriptionLastRefresh(String time) { + return 'آخر تحديث ناجح: ⁨$time⁩'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'التحديث التالي: ⁨$time⁩'; + } + + @override + String get subscriptionStatusHealthy => 'محدّث'; + + @override + String subscriptionStatusIssue(String code) { + return 'مشكلة في التحديث: ⁨$code⁩'; + } + + @override + String get refreshNow => 'تحديث الآن'; + + @override + String get unsubscribe => 'إلغاء الاشتراك'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'إلغاء الاشتراك من «⁨$name⁩»؟'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'يؤدي هذا إلى إزالة الاشتراك المحلي والأحداث المخزنة مؤقتًا. لن يتغير التقويم المنشور.'; + + @override + String get addSubscriptionAction => 'إضافة اشتراك'; + + @override + String subscriptionOperationFailed(String error) { + return 'فشل اشتراك التقويم: ⁨$error⁩'; + } + + @override + String get subscriptions => 'الاشتراكات'; + + @override + String get calendarImport => 'استيراد التقويم'; + + @override + String get calendarImportDescription => + 'حدد ملفًا، وراجع أحداثه، ثم اختر التقويم القابل للكتابة الذي ينبغي أن يستقبلها.'; + + @override + String get importIcsFile => 'استيراد ملف ‎.ics'; + + @override + String get importIcsPreview => 'استيراد أحداث التقويم'; + + @override + String importEventsFound(int count) { + return 'مجموعات أحداث قابلة للاستيراد: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'أحداث غير صالحة: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'تم الاستبعاد عمدًا: ⁨$fields⁩'; + } + + @override + String get noWritableCalendars => 'لا يوجد تقويم وجهة قابل للكتابة.'; + + @override + String get importDestinationCalendar => 'تقويم الوجهة'; + + @override + String get importIcsConfirm => 'استيراد الأحداث'; + + @override + String get importIcsComplete => 'اكتمل الاستيراد'; + + @override + String importQueued(int count) { + return 'تم الاستيراد أو وضعه في قائمة الانتظار: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'تم تخطي التكرارات: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'مجموعات تكرار غير مدعومة: $count'; + } + + @override + String importIcsFailed(String error) { + return 'تعذّر استيراد ملف التقويم: ⁨$error⁩'; } @override @@ -1947,4 +2583,139 @@ class AppLocalizationsAr extends AppLocalizations { @override String get networkOfflineTryAgain => 'أنت غير متصل. اتصل بالإنترنت وحاول مرة أخرى.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'في الأيام $days من الشهر'; + } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(String day) { + return '$day'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first و$second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first، $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first و$second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first، $rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first و$second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first، $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first و$second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first، $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency في يوم $day من $month'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency في الأيام $days من $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency في يوم $day من أشهر $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency في الأيام $days من أشهر $months'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'في أول $days من $month', + 'second': 'في ثاني $days من $month', + 'third': 'في ثالث $days من $month', + 'fourth': 'في رابع $days من $month', + 'fifth': 'في خامس $days من $month', + 'secondToLast': 'في $days قبل الأخير من $month', + 'last': 'في آخر $days من $month', + 'other': 'في $days من $month', + }); + return '$frequency $_temp0'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'في أول $days من أشهر $months', + 'second': 'في ثاني $days من أشهر $months', + 'third': 'في ثالث $days من أشهر $months', + 'fourth': 'في رابع $days من أشهر $months', + 'fifth': 'في خامس $days من أشهر $months', + 'secondToLast': 'في $days قبل الأخير من أشهر $months', + 'last': 'في آخر $days من أشهر $months', + 'other': 'في $days من أشهر $months', + }); + return '$frequency $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 6f1ee48e..d09db222 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -12,12 +12,110 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String get windowsSupport => 'Hilfe'; + + @override + String get windowsThirdPartyLicenses => 'Lizenzen von Drittanbietern'; + + @override + String get windowsSearch => 'Suchen'; + + @override + String get windowsStartupDisabledByUser => + 'Vom Benutzer in den Windows-Einstellungen deaktiviert.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Durch eine Windows-Richtlinie deaktiviert.'; + + @override + String get windowsStartupUnavailable => + 'Verfügbar, nachdem BusyMax aus einem MSIX-Paket installiert wurde.'; + + @override + String get windowsReminderExitNotice => + 'Erinnerungen enden, wenn BusyMax vollständig beendet wird. Lassen Sie es im Hintergrund laufen, um sie zu erhalten.'; + + @override + String get windowsProductVersionLabel => 'Produktversion'; + + @override + String get windowsPackageVersionLabel => 'Windows-Paketversion'; + + @override + String get windowsUnpackaged => 'Nicht paketiert'; + + @override + String get windowsAgendaLoadMore => 'Weitere Agendaeinträge laden'; + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'montags', + 'TU': 'dienstags', + 'WE': 'mittwochs', + 'TH': 'donnerstags', + 'FR': 'freitags', + 'SA': 'samstags', + 'SU': 'sonntags', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'an den Tagen $first und $second des Monats'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency an den Tagen $firstDay und $secondDay im $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency jeweils am $day. im $firstMonth und $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency jeweils an den Tagen $firstDay und $secondDay im $firstMonth und $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency jeweils an den Tagen $days im $firstMonth und $secondMonth'; + } + @override String get appTitle => 'BusyMax'; @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Verbinden Sie Google-, Microsoft-, Apple-iCloud-Kalender- oder Nextcloud-Konten.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +139,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Fügen Sie alle Konten hinzu, die Sie verwenden möchten. BusyMax synchronisiert unterstützte Kalender, Ereignisse, Aufgabenlisten und Aufgaben aus jedem Konto.'; @override String get onboardingPreferencesStepTitle => 'Systemeinstellungen wählen'; @@ -117,6 +215,44 @@ class AppLocalizationsDe extends AppLocalizations { @override String get calendars => 'Kalender'; + @override + String get newCalendar => 'Neuer Kalender'; + + @override + String get calendarColor => 'Kalenderfarbe'; + + @override + String calendarColorOption(int number) { + return 'Farbe $number'; + } + + @override + String get calendarManagementUnsupported => + 'Dieser Anbieter unterstützt die Kalenderverwaltung in BusyMax nicht.'; + + @override + String get primaryCalendarCannotDelete => + 'Der primäre Kalender kann nicht gelöscht werden.'; + + @override + String calendarCreateFailed(String error) { + return 'Kalender konnte nicht erstellt werden: $error'; + } + + @override + String get calendarCreatedRefreshPending => + 'Der Kalender wurde erstellt, aber BusyMax konnte das Konto nicht aktualisieren. Er erscheint nach der nächsten Synchronisierung.'; + + @override + String calendarUpdateFailed(String error) { + return 'Kalender konnte nicht aktualisiert werden: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Kalender konnte nicht gelöscht werden: $error'; + } + @override String get newEvent => 'Neuer Termin'; @@ -140,7 +276,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String moreItems(int count) { - return '+$count mehr'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString mehr'; } @override @@ -180,6 +320,101 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trayOpenBusyMax => 'BusyMax öffnen'; + @override + String get trayShowBusyMax => 'BusyMax anzeigen'; + + @override + String get trayNewEvent => 'Neuer Termin…'; + + @override + String get trayNewTask => 'Neue Aufgabe…'; + + @override + String get trayToday => 'Heute'; + + @override + String get trayAllDay => 'Ganztägig'; + + @override + String get trayNow => 'Jetzt'; + + @override + String get trayCalendarEvent => 'Kalendertermin'; + + @override + String get trayUntitledEvent => 'Termin ohne Titel'; + + @override + String get trayNothingElseToday => 'Heute nichts Weiteres'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Aufgaben heute fällig', + one: '1 Aufgabe heute fällig', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Heutige Agenda öffnen'; + + @override + String get traySyncNow => 'Jetzt synchronisieren'; + + @override + String get traySyncing => 'Synchronisierung…'; + + @override + String get trayNotConnected => 'Nicht verbunden'; + + @override + String get trayNotYetSynced => 'Noch nicht synchronisiert'; + + @override + String get trayLastSyncedJustNow => 'Gerade synchronisiert'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Vor $count Minuten synchronisiert', + one: 'Vor 1 Minute synchronisiert', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Vor $count Stunden synchronisiert', + one: 'Vor 1 Stunde synchronisiert', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Vor $count Tagen synchronisiert', + one: 'Vor 1 Tag synchronisiert', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Einstellungen'; + + @override + String get trayQuitBusyMax => 'BusyMax beenden'; + @override String get agendaLoadMoreOverdue => 'Weitere überfällige Aufgaben laden'; @@ -199,7 +434,7 @@ class AppLocalizationsDe extends AppLocalizations { String get viewYear => 'Jahr'; @override - String get viewAgenda => 'Agenda'; + String get viewAgenda => 'Tagesübersicht'; @override String get scheduleSettings => 'Zeitplan'; @@ -263,127 +498,128 @@ class AppLocalizationsDe extends AppLocalizations { String get noGuests => 'Keine Gäste'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Erforderlich'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Freiwillig'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Besprechung'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Google Meet hinzufügen'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Microsoft-Teams-Besprechung hinzufügen'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Onlinebesprechung hinzugefügt'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Antworten anfordern'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'Bitten Sie Gäste, auf die Einladung zu antworten.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Gästeliste ausblenden'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'Gäste können nicht sehen, wer sonst eingeladen wurde.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'Neue Zeitvorschläge zulassen'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Gäste können eine andere Besprechungszeit vorschlagen.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'Gäste benachrichtigen?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'Diese Besprechung hat Gäste. Sollen beim Speichern Einladungen oder Terminaktualisierungen gesendet werden?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'Diese Besprechung hat Gäste. Soll beim Löschen eine Absage gesendet werden?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Aktualisierungen senden'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Absage senden'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'Nicht senden'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'Besprechung speichern?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft sendet Einladungen oder Terminaktualisierungen an die Gäste.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'Besprechung löschen?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft sendet eine Absage an die Gäste.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Organisator'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'Ihre Antwort'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Gästeantworten'; @override - String get respond => 'Respond'; + String get respond => 'Antworten'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Annehmen'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Mit Vorbehalt'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Ablehnen'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'An Besprechung teilnehmen'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Angenommen'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Mit Vorbehalt'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Abgelehnt'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'Antwort ausstehend'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Nicht beantwortet'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Organisator'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'Ihre Antwort konnte nicht gesendet werden: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => + 'Der Besprechungslink konnte nicht geöffnet werden.'; @override String get description => 'Beschreibung'; @@ -508,7 +744,7 @@ class AppLocalizationsDe extends AppLocalizations { String get taskLists => 'Aufgabenlisten'; @override - String get navigation => 'Navigation'; + String get navigation => 'Bedienung'; @override String get mainMenu => 'Hauptmenü'; @@ -524,7 +760,7 @@ class AppLocalizationsDe extends AppLocalizations { 'Diese Übersicht der Tastenkürzel anzeigen'; @override - String get shortcutGroupNavigation => 'Navigation'; + String get shortcutGroupNavigation => 'Bedienung'; @override String get shortcutNextPeriod => 'Nächster Zeitraum'; @@ -593,7 +829,7 @@ class AppLocalizationsDe extends AppLocalizations { String get apacheLicenseName => 'Apache License 2.0'; @override - String get website => 'Website'; + String get website => 'Webseite'; @override String get sourceCode => 'Quellcode'; @@ -644,7 +880,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Fügt nur die Version Ihres Linux-Betriebssystems und die Spracheinstellung der Anwendung hinzu. Es werden keine Protokolle, Kontodaten, Dateinamen oder anderen Diagnosedaten hinzugefügt.'; + 'Fügt nur Name und Version Ihres Betriebssystems sowie die Spracheinstellung der Anwendung hinzu. Es werden keine Protokolle, Kontodaten, Dateinamen oder anderen Diagnosedaten hinzugefügt.'; @override String get feedbackCategoryRequired => 'Wählen Sie eine Kategorie aus.'; @@ -736,7 +972,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'Dadurch werden zwischengespeicherte Aufgaben, Kalender, Termine, Erinnerungen und ausstehende Offline-Änderungen von diesem Gerät gelöscht. Nicht synchronisierte Änderungen gehen verloren. Kopien der Kalender, Termine, Aufgabenlisten und Aufgaben beim Anbieter werden nicht gelöscht.'; @override String get revokeGoogleAccess => @@ -762,17 +998,17 @@ class AppLocalizationsDe extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'Aufgabenliste konnte nicht erstellt werden: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'Aufgabenliste konnte nicht umbenannt werden: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'Aufgabenliste konnte nicht gelöscht werden: $error'; } @override @@ -802,11 +1038,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'Diese Aufgabenliste ist schreibgeschützt und kann nicht umbenannt werden.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'Diese Aufgabenliste kann mit Ihren aktuellen Berechtigungen nicht gelöscht werden.'; @override String get builtInMicrosoftList => 'Integriert'; @@ -817,17 +1053,17 @@ class AppLocalizationsDe extends AppLocalizations { @override String deleteListConfirmation(String title) { - return '\"$title\" aus Google Tasks löschen?'; + return '„$title“ aus Google Tasks löschen?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return '„$title“ und alle Aufgaben löschen?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'Freigabe von „$title“ für dieses Konto aufheben?'; } @override @@ -947,7 +1183,7 @@ class AppLocalizationsDe extends AppLocalizations { String get primarySection => 'Primär'; @override - String get statusSection => 'Status'; + String get statusSection => 'Aufgabenstatus'; @override String get openStatus => 'Offen'; @@ -956,10 +1192,10 @@ class AppLocalizationsDe extends AppLocalizations { String get doneStatus => 'Erledigt'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'Aufgabenstatus'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Kein Status'; @override String get taskStatusNeedsAction => 'Handlungsbedarf'; @@ -968,60 +1204,79 @@ class AppLocalizationsDe extends AppLocalizations { String get taskStatusInProcess => 'In Bearbeitung'; @override - String get taskStatusCompleted => 'Fertiggestellt'; + String get taskStatusCompleted => 'Erledigt'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Abgebrochen'; @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% abgeschlossen'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Abschlussdatum'; @override String get priority => 'Priorität'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Keine Priorität'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorität $priorityString · Hoch'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorität $priorityString · Mittel'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorität $priorityString · Niedrig'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'Aufgaben-URL'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => + 'Geben Sie eine absolute URL einschließlich ihres Schemas ein.'; @override - String get classification => 'Classification'; + String get classification => 'Klassifizierung'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => + 'Bei Freigabe die vollständige Aufgabe anzeigen'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => + 'Bei Freigabe nur den Belegt-Status anzeigen'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'Diese Aufgabe bei Freigabe ausblenden'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Aufgabe anheften'; @override String get notes => 'Notizen'; @@ -1060,75 +1315,79 @@ class AppLocalizationsDe extends AppLocalizations { String get addReminder => 'Erinnerung hinzufügen'; @override - String get reminders => 'Reminders'; + String get reminders => 'Erinnerungen'; @override String get noReminders => 'Keine Erinnerungen'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Erinnerung bearbeiten'; @override - String get beforeTaskStarts => 'Bevor die Aufgabe startet'; + String get beforeTaskStarts => 'Vor Beginn der Aufgabe'; @override - String get beforeTaskDue => 'Bevor die Aufgabe fällig ist'; + String get beforeTaskDue => 'Vor Fälligkeit der Aufgabe'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'Nach Beginn der Aufgabe'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'Nach Fälligkeit der Aufgabe'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'Relativ zum Startdatum der Aufgabe'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'Relativ zum Fälligkeitsdatum der Aufgabe'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Tageszeit'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'Zu einem Datum und einer Uhrzeit'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Menge'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Einheit'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Sekunden'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'Minuten'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Stunden'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Tage'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Wochen'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'Zu Beginn der Aufgabe'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'Zum Fälligkeitszeitpunkt der Aufgabe'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'Dieser Erinnerungstyp bleibt erhalten, aber seine Zeit kann nicht bearbeitet werden.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'Verknüpfte Erinnerungen behalten?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Für dieses Datum gibt es $countString verknüpfte Erinnerungen. Sollen sie ihr aktuelles Datum und ihre aktuelle Uhrzeit behalten?'; } @override @@ -1171,111 +1430,165 @@ class AppLocalizationsDe extends AppLocalizations { String get repeatYearly => 'Jährlich'; @override - String get repeatEvery => 'Wiederhole jeden'; + String get repeatEvery => 'Intervall'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'Wiederholen an'; @override String get repeatEnd => 'Wiederholung beenden'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'Nie'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'An einem Datum'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'Nach einer Anzahl von Wiederholungen'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'Wiederholungen'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'Tage des Monats'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'Monate'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'Wochentagsposition'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'Bestimmte Tage'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'Erste'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'Zweite'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'Dritte'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'Vierte'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'Fünfte'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'Vorletzte'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'Letzte'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'Tag'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'Wochentag'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'Wochenendtag'; + + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'Montag', + 'TU': 'Dienstag', + 'WE': 'Mittwoch', + 'TH': 'Donnerstag', + 'FR': 'Freitag', + 'SA': 'Samstag', + 'SU': 'Sonntag', + 'day': 'Tag', + 'weekday': 'Wochentag', + 'weekend': 'Wochenendtag', + 'other': '$day', + }); + return '$_temp0'; + } @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Alle $countString Tage'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Alle $countString Wochen'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Alle $countString Monate'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Alle $countString Jahre'; } @override String repeatOnDaysSummary(String days) { - return 'an $days'; + return '$days'; } @override String repeatOnMonthDaysSummary(String days) { - return 'am Tag $days'; + return 'am $days. Tag'; } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'am ersten $days', + 'second': 'am zweiten $days', + 'third': 'am dritten $days', + 'fourth': 'am vierten $days', + 'fifth': 'am fünften $days', + 'secondToLast': 'am vorletzten $days', + 'last': 'am letzten $days', + 'other': 'an $days', + }); + return '$_temp0'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return 'in den Monaten $months'; } @override String repeatTimesSummary(int count) { - return '$count mal'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString-mal', + one: '$countString Mal', + ); + return '$_temp0'; } @override @@ -1285,7 +1598,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'Diese Wiederholungsregel verwendet Optionen, die dieser Editor nicht ändert.'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'Diese Wiederholung kann nicht mit $provider verwendet werden.'; + } @override String get importance => 'Wichtigkeit'; @@ -1294,7 +1612,7 @@ class AppLocalizationsDe extends AppLocalizations { String get importanceLow => 'Niedrig'; @override - String get importanceNormal => 'Normal'; + String get importanceNormal => 'Mittel'; @override String get importanceHigh => 'Hoch'; @@ -1309,7 +1627,7 @@ class AppLocalizationsDe extends AppLocalizations { String get dueGroup => 'Fällig'; @override - String get startGroup => 'Start'; + String get startGroup => 'Beginn'; @override String get reminderGroup => 'Erinnerung'; @@ -1343,11 +1661,11 @@ class AppLocalizationsDe extends AppLocalizations { String get duplicateTask => 'Aufgabe duplizieren'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Aufgabe dupliziert.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'Aufgabe konnte nicht dupliziert werden: $error'; } @override @@ -1386,7 +1704,7 @@ class AppLocalizationsDe extends AppLocalizations { String get parent => 'Übergeordnete Aufgabe'; @override - String get position => 'Position'; + String get position => 'Reihenfolge'; @override String get webLink => 'Weblink'; @@ -1410,7 +1728,11 @@ class AppLocalizationsDe extends AppLocalizations { String get sync => 'Synchronisierung'; @override - String get manualFullSync => 'Manuelle vollständige Synchronisierung'; + String get forceFullResync => 'Vollständige Neusynchronisierung erzwingen'; + + @override + String get forceFullResyncDescription => + 'Lädt alle Daten aus jedem verbundenen Konto vollständig neu. Verwenden Sie diese Option nur zur Behebung von Synchronisierungsproblemen.'; @override String get runInBackgroundWhenClosed => @@ -1459,6 +1781,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get eventReminders => 'Terminerinnerungen'; + @override + String get onState => 'Ein'; + @override String get taskReminders => 'Aufgabenerinnerungen'; @@ -1469,7 +1794,7 @@ class AppLocalizationsDe extends AppLocalizations { String get notificationDetailPrivate => 'Privat'; @override - String get notificationDetailNormal => 'Normal'; + String get notificationDetailNormal => 'Standard'; @override String get quietHours => 'Ruhezeiten'; @@ -1487,6 +1812,18 @@ class AppLocalizationsDe extends AppLocalizations { @override String get notifications => 'Benachrichtigungen'; + @override + String get windowsNotificationsUnavailable => + 'Windows-Benachrichtigungen sind nicht verfügbar'; + + @override + String get windowsNotificationsUnpackaged => + 'Diese nicht paketierte Entwicklungsausführung kann keine Windows-Benachrichtigungen verwenden. Installieren Sie das testsignierte MSIX, um Erinnerungen zu testen.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax konnte Windows-Benachrichtigungen nicht initialisieren. Erinnerungen werden erst angezeigt, wenn dieses Installationsproblem behoben ist.'; + @override String get appearance => 'Darstellung'; @@ -1494,7 +1831,10 @@ class AppLocalizationsDe extends AppLocalizations { String get theme => 'Design'; @override - String get themeSystem => 'System'; + String get themeSystem => 'Systemstandard'; + + @override + String get settingsSystem => 'System'; @override String get themeLight => 'Hell'; @@ -1574,7 +1914,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'Versuche=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Versuche=$countString'; } @override @@ -1628,10 +1972,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count Aufgaben sind heute fällig.', + other: '$countString Aufgaben sind heute fällig.', one: 'Eine Aufgabe ist heute fällig.', ); return '$_temp0'; @@ -1656,7 +2004,7 @@ class AppLocalizationsDe extends AppLocalizations { String get notificationSnoozeAction => 'In 10 Minuten erinnern'; @override - String get notificationDismissAction => 'Verwerfen'; + String get notificationDismissAction => 'Schließen'; @override String get notificationDetailsHidden => @@ -1713,14 +2061,14 @@ class AppLocalizationsDe extends AppLocalizations { String get noLocationsFound => 'Keine Orte gefunden'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'Dieses Feld ist erforderlich.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Verbinden Sie Kalender und Aufgaben mit einem dieser Anbieter.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Apple-iCloud-Kalender'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1729,198 +2077,224 @@ class AppLocalizationsDe extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Nextcloud-Aufgaben'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'Apple-iCloud-Kalenderkonto hinzufügen'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Nextcloud-Konto hinzufügen'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => + 'Verbindung mit Apple iCloud wird hergestellt…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Warten auf die Nextcloud-Autorisierung…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Apple-iCloud-Kalender verbinden'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'E-Mail des Apple-Accounts'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'App-spezifisches Passwort'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Erstellen Sie nach dem Aktivieren der Zwei-Faktor-Authentifizierung für Ihren Apple-Account ein app-spezifisches Passwort.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Durch das Zurücksetzen Ihres Apple-Account-Passworts werden app-spezifische Passwörter widerrufen.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Nextcloud verbinden'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Nextcloud-Server oder CalDAV-Adresse'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Geben Sie die URL Ihres Nextcloud-Servers ein oder fügen Sie die aus Nextcloud kopierte primäre CalDAV-Adresse ein.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax öffnet Ihren Browser. Genehmigen Sie dort den Zugriff und kehren Sie anschließend zu BusyMax zurück.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Verbinden'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Verbindung abbrechen'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'Das Konto wurde lokal entfernt, aber das Nextcloud-App-Passwort konnte nicht widerrufen werden.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'Kalender- und Aufgabendaten werden für die Offline-Nutzung lokal zwischengespeichert.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Verbinden Sie dieses Konto erneut, um die Synchronisierung fortzusetzen.'; @override String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + 'Dieses Konto ist vorübergehend nicht verfügbar.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'Die Serverberechtigungen wurden geändert. Ausstehende Bearbeitungen sind pausiert.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Dieser Server oder dieses Anbieterprofil wird nicht unterstützt.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Kalender und Aufgabenlisten'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'Kalendertermine'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Aufgaben'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Schreibgeschützt'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'Lokal ausstehend'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Durch Konflikt blockiert'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'Bis zur erneuten Verbindung blockiert'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'Vorgang fehlgeschlagen'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Serverversion behalten'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'Lokale Änderung prüfen und erneut anwenden'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Als neues Element duplizieren'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'Verbindungsstatus'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Verbunden'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'Verbindung wird hergestellt…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Abgemeldet'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Letzte erfolgreiche Synchronisierung: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Noch nicht synchronisiert'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'Kalender und Aufgabenlisten aktualisieren'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Serveradresse: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Terminkalender'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Aufgabenliste'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'Termine und Aufgaben'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Beschreibbar'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Freigegeben'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Zuletzt synchronisiert: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Synchronisierungsproblem: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Synchronisierungskonflikte'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Server geändert: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Lokale Änderung: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => + 'Der Konflikt konnte nicht gelöst werden.'; + + @override + String get recurringEventScope => 'Bereich des wiederkehrenden Termins'; + + @override + String get entireSeries => 'Gesamte Serie'; + + @override + String get singleOccurrence => 'Dieser Termin'; + + @override + String get thisAndFollowingEvents => 'Dieser und die folgenden Termine'; + + @override + String get thisAndFutureUnavailable => + 'Von diesem Anbieter nicht unterstützt.'; + + @override + String get thisAndFutureMoveUnavailable => + 'Dieser Termin und die folgenden Termine können nicht sicher verschoben werden. Wählen Sie diesen Termin oder die gesamte Serie.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get entireSeriesMoveUnavailable => + 'Die Wiederholungsregel ist lokal nicht verfügbar. Verschieben Sie stattdessen diesen Termin.'; @override - String get entireSeries => 'Entire series'; + String get copyEventAndDeleteOriginal => + 'Termin kopieren und Original löschen?'; @override - String get singleOccurrence => 'This occurrence'; + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax kann diesen Termin nicht direkt von $source nach $destination verschieben. Zuerst wird die Kopie erstellt; das Original wird erst nach erfolgreichem Kopieren gelöscht. Termin-IDs ändern sich; Antwortstatus von Teilnehmern können zurückgesetzt und Einladungen oder Absagen versendet werden; Konferenzlinks, Anhänge, Erinnerungen, anbieterspezifische Felder und Wiederholungsausnahmen werden möglicherweise nicht übernommen.'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => 'Kopieren und löschen'; @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Wählen Sie, ob diese Änderung für die gesamte Serie, nur diesen Termin oder diesen und die folgenden Termine gilt.'; @override String get taskDueBeforeStart => @@ -1928,7 +2302,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get taskStartDueTimeModeMismatch => - 'Lege für Beginn und Fälligkeit jeweils eine Uhrzeit fest oder mache die Aufgabe ganztägig.'; + 'Legen Sie für Beginn und Fälligkeit jeweils eine Uhrzeit fest oder machen Sie die Aufgabe ganztägig.'; @override String deleteCalendarConfirmation(String title) { @@ -1936,7 +2310,202 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get networkOffline => 'Offline'; + String get setCustomCalendarName => 'Benutzerdefinierten Namen festlegen'; + + @override + String get setAction => 'Festlegen'; + + @override + String get removeFromMyCalendars => 'Aus meinen Kalendern entfernen'; + + @override + String get removeAction => 'Entfernen'; + + @override + String removeCalendarConfirmation(String title) { + return '„$title“ aus Ihrer Google-Kalenderliste entfernen? Der freigegebene Kalender und seine Termine werden nicht gelöscht.'; + } + + @override + String get calendarCannotRemove => + 'Dieser Kalender kann nicht gelöscht oder aus diesem Konto entfernt werden.'; + + @override + String get calendarPendingChangesPreventRemoval => + 'Warten Sie, bis die ausstehenden Änderungen dieses Kalenders synchronisiert wurden, bevor Sie ihn löschen oder entfernen.'; + + @override + String get calendarSubscriptions => 'Kalenderabonnements'; + + @override + String get calendarSubscriptionsDescription => + 'Fügen Sie schreibgeschützte Kalender hinzu, die über eine sichere WebCal-URL aktualisiert werden.'; + + @override + String get addCalendarSubscription => 'Kalenderabonnement hinzufügen'; + + @override + String get subscriptionName => 'Lokaler Name'; + + @override + String get subscriptionUrl => 'Abonnement-URL'; + + @override + String get subscriptionUrlHelp => + 'Geben Sie eine HTTPS- oder webcal-URL ein. BusyMax speichert die vollständige URL sicher.'; + + @override + String get subscriptionUrlInvalid => + 'Geben Sie eine gültige HTTPS- oder webcal-URL ohne Benutzerinformationen oder Fragment ein.'; + + @override + String get subscriptionColor => 'Lokale Farbe'; + + @override + String get subscriptionColorHelp => + 'Verwenden Sie eine sechsstellige Farbe wie #3584E4.'; + + @override + String get subscriptionColorInvalid => + 'Geben Sie eine sechsstellige Hexadezimalfarbe ein.'; + + @override + String get subscriptionRefreshMode => 'Aktualisierungshäufigkeit'; + + @override + String get subscriptionAutomatic => 'Automatisch'; + + @override + String get subscriptionHourly => 'Stündlich'; + + @override + String get subscriptionSixHours => 'Alle sechs Stunden'; + + @override + String get subscriptionDaily => 'Täglich'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Quelle: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Geben Sie eine gültige URL ein, um ihre sichere Quelle anzuzeigen.'; + + @override + String get subscriptionReadOnly => 'Schreibgeschütztes Abonnement'; + + @override + String get subscriptionNeverRefreshed => 'Noch nicht aktualisiert'; + + @override + String subscriptionLastRefresh(String time) { + return 'Letzte erfolgreiche Aktualisierung: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Nächste Aktualisierung: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Aktuell'; + + @override + String subscriptionStatusIssue(String code) { + return 'Aktualisierungsproblem: $code'; + } + + @override + String get refreshNow => 'Jetzt aktualisieren'; + + @override + String get unsubscribe => 'Abbestellen'; + + @override + String unsubscribeCalendarTitle(String name) { + return '„$name“ abbestellen?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'Dadurch werden das lokale Abonnement und die zwischengespeicherten Termine entfernt. Der veröffentlichte Kalender wird nicht geändert.'; + + @override + String get addSubscriptionAction => 'Abonnement hinzufügen'; + + @override + String subscriptionOperationFailed(String error) { + return 'Kalenderabonnement fehlgeschlagen: $error'; + } + + @override + String get subscriptions => 'Abonnements'; + + @override + String get calendarImport => 'Kalenderimport'; + + @override + String get calendarImportDescription => + 'Wählen Sie eine Datei aus, prüfen Sie ihre Termine und wählen Sie anschließend den beschreibbaren Kalender aus, der sie aufnehmen soll.'; + + @override + String get importIcsFile => '‎.ics-Datei importieren'; + + @override + String get importIcsPreview => 'Kalendertermine importieren'; + + @override + String importEventsFound(int count) { + return 'Importierbare Terminserien: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Ungültige Termine: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Absichtlich ausgelassen: $fields'; + } + + @override + String get noWritableCalendars => + 'Kein beschreibbarer Zielkalender verfügbar.'; + + @override + String get importDestinationCalendar => 'Zielkalender'; + + @override + String get importIcsConfirm => 'Termine importieren'; + + @override + String get importIcsComplete => 'Import abgeschlossen'; + + @override + String importQueued(int count) { + return 'Importiert oder in die Warteschlange gestellt: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Übersprungene Duplikate: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Nicht unterstützte Wiederholungsserien: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Kalenderdatei konnte nicht importiert werden: $error'; + } + + @override + String get networkOffline => 'Ohne Verbindung'; @override String get networkOfflineDescription => @@ -1945,4 +2514,139 @@ class AppLocalizationsDe extends AppLocalizations { @override String get networkOfflineTryAgain => 'Sie sind offline. Stellen Sie eine Internetverbindung her und versuchen Sie es erneut.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'an den Tagen $days des Monats'; + } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(String day) { + return '$day'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first und $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first und $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first und $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first und $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency am $day. $month'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency an den Tagen $days im $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency jeweils am $day. in $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency jeweils an den Tagen $days in $months'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'am ersten', + 'second': 'am zweiten', + 'third': 'am dritten', + 'fourth': 'am vierten', + 'fifth': 'am fünften', + 'secondToLast': 'am vorletzten', + 'last': 'am letzten', + 'other': 'an', + }); + return '$frequency $_temp0 $days im $month'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'jeweils am ersten', + 'second': 'jeweils am zweiten', + 'third': 'jeweils am dritten', + 'fourth': 'jeweils am vierten', + 'fifth': 'jeweils am fünften', + 'secondToLast': 'jeweils am vorletzten', + 'last': 'jeweils am letzten', + 'other': 'jeweils an', + }); + return '$frequency $_temp0 $days in $months'; + } } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 921aaa36..617d31e9 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -12,6 +12,103 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get windowsSupport => 'Support'; + + @override + String get windowsThirdPartyLicenses => 'Third-party licenses'; + + @override + String get windowsSearch => 'Search'; + + @override + String get windowsStartupDisabledByUser => + 'Disabled by the user in Windows Settings.'; + + @override + String get windowsStartupDisabledByPolicy => 'Disabled by Windows policy.'; + + @override + String get windowsStartupUnavailable => + 'Available after BusyMax is installed from an MSIX package.'; + + @override + String get windowsReminderExitNotice => + 'Reminders stop when BusyMax is fully quit. Keep it running in the background to receive them.'; + + @override + String get windowsProductVersionLabel => 'Product version'; + + @override + String get windowsPackageVersionLabel => 'Windows package version'; + + @override + String get windowsUnpackaged => 'Unpackaged'; + + @override + String get windowsAgendaLoadMore => 'Load more agenda items'; + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'Monday', + 'TU': 'Tuesday', + 'WE': 'Wednesday', + 'TH': 'Thursday', + 'FR': 'Friday', + 'SA': 'Saturday', + 'SU': 'Sunday', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'on days $first and $second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency on days $firstDay and $secondDay of $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency on day $day of $firstMonth and $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency on days $firstDay and $secondDay of $firstMonth and $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency on days $days of $firstMonth and $secondMonth'; + } + @override String get appTitle => 'BusyMax'; @@ -117,6 +214,48 @@ class AppLocalizationsEn extends AppLocalizations { @override String get calendars => 'Calendars'; + @override + String get newCalendar => 'New calendar'; + + @override + String get calendarColor => 'Calendar color'; + + @override + String calendarColorOption(int number) { + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Color $numberString'; + } + + @override + String get calendarManagementUnsupported => + 'This provider does not support calendar management in BusyMax.'; + + @override + String get primaryCalendarCannotDelete => + 'The primary calendar cannot be deleted.'; + + @override + String calendarCreateFailed(String error) { + return 'Could not create the calendar: $error'; + } + + @override + String get calendarCreatedRefreshPending => + 'The calendar was created, but BusyMax could not refresh the account. It will appear after the next sync.'; + + @override + String calendarUpdateFailed(String error) { + return 'Could not update the calendar: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Could not delete the calendar: $error'; + } + @override String get newEvent => 'New event'; @@ -140,7 +279,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String moreItems(int count) { - return '+$count more'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString more'; } @override @@ -179,6 +322,117 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trayOpenBusyMax => 'Open BusyMax'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $countString minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $countString hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $countString days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => 'Load more overdue tasks'; @@ -428,10 +682,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes minutes before', + other: '$minutesString minutes before', one: '1 minute before', ); return '$_temp0'; @@ -442,10 +700,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours hours before', + other: '$hoursString hours before', one: '1 hour before', ); return '$_temp0'; @@ -453,10 +715,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days days before', + other: '$daysString days before', one: '1 day before', ); return '$_temp0'; @@ -641,7 +908,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Adds only your Linux operating-system version and application locale. No logs, account data, file names, or other diagnostics are included.'; + 'Adds only your operating-system name and version and application locale. No logs, account data, file names, or other diagnostics are included.'; @override String get feedbackCategoryRequired => 'Select a category.'; @@ -957,7 +1224,7 @@ class AppLocalizationsEn extends AppLocalizations { String get taskStatusNeedsAction => 'Needs action'; @override - String get taskStatusInProcess => 'In process'; + String get taskStatusInProcess => 'In progress'; @override String get taskStatusCompleted => 'Completed'; @@ -967,7 +1234,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% completed'; } @override @@ -981,17 +1252,29 @@ class AppLocalizationsEn extends AppLocalizations { @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priority $priorityString · High'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priority $priorityString · Medium'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priority $priorityString · Low'; } @override @@ -1120,7 +1403,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'This date has $countString related reminders. Keep them at their current date and time?'; } @override @@ -1163,7 +1450,7 @@ class AppLocalizationsEn extends AppLocalizations { String get repeatYearly => 'Yearly'; @override - String get repeatEvery => 'Repeat every'; + String get repeatEvery => 'Interval'; @override String get repeatOn => 'Repeat on'; @@ -1225,24 +1512,58 @@ class AppLocalizationsEn extends AppLocalizations { @override String get repeatWeekendDay => 'Weekend day'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'Monday', + 'TU': 'Tuesday', + 'WE': 'Wednesday', + 'TH': 'Thursday', + 'FR': 'Friday', + 'SA': 'Saturday', + 'SU': 'Sunday', + 'day': 'day', + 'weekday': 'weekday', + 'weekend': 'weekend day', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Every $countString days'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Every $countString weeks'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Every $countString months'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Every $countString years'; } @override @@ -1256,8 +1577,18 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'on the first $days', + 'second': 'on the second $days', + 'third': 'on the third $days', + 'fourth': 'on the fourth $days', + 'fifth': 'on the fifth $days', + 'secondToLast': 'on the second to last $days', + 'last': 'on the last $days', + 'other': 'on $days', + }); + return '$_temp0'; } @override @@ -1267,7 +1598,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count times'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString times', + one: '$countString time', + ); + return '$_temp0'; } @override @@ -1279,6 +1620,11 @@ class AppLocalizationsEn extends AppLocalizations { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'This recurrence cannot be used with $provider.'; + } + @override String get importance => 'Importance'; @@ -1402,7 +1748,11 @@ class AppLocalizationsEn extends AppLocalizations { String get sync => 'Sync'; @override - String get manualFullSync => 'Manual full sync'; + String get forceFullResync => 'Force full resync'; + + @override + String get forceFullResyncDescription => + 'Completely reload data from every connected account. Use this only to troubleshoot synchronization problems.'; @override String get runInBackgroundWhenClosed => @@ -1448,6 +1798,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get eventReminders => 'Event reminders'; + @override + String get onState => 'On'; + @override String get taskReminders => 'Task reminders'; @@ -1475,6 +1828,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notifications => 'Notifications'; + @override + String get windowsNotificationsUnavailable => + 'Windows notifications are unavailable'; + + @override + String get windowsNotificationsUnpackaged => + 'This unpackaged development run cannot use Windows notifications. Install the test-signed MSIX to test reminders.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax could not initialize Windows notifications. Reminders will not appear until this installation problem is resolved.'; + @override String get appearance => 'Appearance'; @@ -1484,6 +1849,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get themeSystem => 'System'; + @override + String get settingsSystem => 'System'; + @override String get themeLight => 'Light'; @@ -1560,7 +1928,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'attempts=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'attempts=$countString'; } @override @@ -1613,10 +1985,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks are due today.', + other: '$countString tasks are due today.', one: 'One task is due today.', ); return '$_temp0'; @@ -1667,7 +2043,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'Week $number'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Week $numberString'; } @override @@ -1675,10 +2055,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count items', + other: '$countString items', one: '1 item', ); return '$_temp0'; @@ -1897,14 +2281,36 @@ class AppLocalizationsEn extends AppLocalizations { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => 'This event'; + + @override + String get thisAndFollowingEvents => 'This and following events'; + + @override + String get thisAndFutureUnavailable => 'Not supported by this provider.'; + + @override + String get thisAndFutureMoveUnavailable => + 'This and following events cannot be moved safely. Choose this event or the entire series.'; @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get entireSeriesMoveUnavailable => + 'The recurrence rule is not available locally. Move this event instead.'; + + @override + String get copyEventAndDeleteOriginal => 'Copy event and delete original?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax cannot move this event directly from $source to $destination. It will create the copy first and delete the original only after the copy succeeds. Event IDs will change; attendee response statuses may reset and invitations or cancellations may be sent; conference links, attachments, reminders, provider-specific fields, and recurrence exceptions may not carry over.'; + } + + @override + String get copyAndDelete => 'Copy and delete'; @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Choose whether this change applies to the entire series, only this occurrence, or this and following events.'; @override String get taskDueBeforeStart => 'Due must not be before start.'; @@ -1918,6 +2324,219 @@ class AppLocalizationsEn extends AppLocalizations { return 'Delete \"$title\"?'; } + @override + String get setCustomCalendarName => 'Set custom name'; + + @override + String get setAction => 'Set'; + + @override + String get removeFromMyCalendars => 'Remove from my calendars'; + + @override + String get removeAction => 'Remove'; + + @override + String removeCalendarConfirmation(String title) { + return 'Remove \"$title\" from your Google Calendar list? The shared calendar and its events will not be deleted.'; + } + + @override + String get calendarCannotRemove => + 'This calendar cannot be deleted or removed from this account.'; + + @override + String get calendarPendingChangesPreventRemoval => + 'Wait for this calendar’s pending changes to finish syncing before deleting or removing it.'; + + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get calendarImport => 'Calendar import'; + + @override + String get calendarImportDescription => + 'Select a file, review its events, then choose the writable calendar that should receive them.'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Importable event sets: $countString'; + } + + @override + String importInvalidEvents(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Invalid events: $countString'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Imported or queued: $countString'; + } + + @override + String importDuplicatesSkipped(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Duplicates skipped: $countString'; + } + + @override + String importUnsupportedSets(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Unsupported recurrence sets: $countString'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => 'Offline'; @@ -1928,4 +2547,139 @@ class AppLocalizationsEn extends AppLocalizations { @override String get networkOfflineTryAgain => 'You’re offline. Connect to the internet and try again.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'on days $days'; + } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(String day) { + return '$day'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first and $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first and $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first and $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first and $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency on $month $day'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency on days $days of $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency on day $day of $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency on days $days of $months'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'on the first $days of $month', + 'second': 'on the second $days of $month', + 'third': 'on the third $days of $month', + 'fourth': 'on the fourth $days of $month', + 'fifth': 'on the fifth $days of $month', + 'secondToLast': 'on the second to last $days of $month', + 'last': 'on the last $days of $month', + 'other': 'on $days of $month', + }); + return '$frequency $_temp0'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'on the first $days of $months', + 'second': 'on the second $days of $months', + 'third': 'on the third $days of $months', + 'fourth': 'on the fourth $days of $months', + 'fifth': 'on the fifth $days of $months', + 'secondToLast': 'on the second to last $days of $months', + 'last': 'on the last $days of $months', + 'other': 'on $days of $months', + }); + return '$frequency $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 8f81ebfb..37743cf5 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -12,12 +12,110 @@ import 'app_localizations.dart'; class AppLocalizationsEs extends AppLocalizations { AppLocalizationsEs([String locale = 'es']) : super(locale); + @override + String get windowsSupport => 'Soporte'; + + @override + String get windowsThirdPartyLicenses => 'Licencias de terceros'; + + @override + String get windowsSearch => 'Buscar'; + + @override + String get windowsStartupDisabledByUser => + 'Desactivado por el usuario en Configuración de Windows.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Desactivado por una directiva de Windows.'; + + @override + String get windowsStartupUnavailable => + 'Disponible después de instalar BusyMax desde un paquete MSIX.'; + + @override + String get windowsReminderExitNotice => + 'Los recordatorios se detienen al cerrar BusyMax por completo. Déjalo ejecutándose en segundo plano para recibirlos.'; + + @override + String get windowsProductVersionLabel => 'Versión del producto'; + + @override + String get windowsPackageVersionLabel => 'Versión del paquete de Windows'; + + @override + String get windowsUnpackaged => 'Sin empaquetar'; + + @override + String get windowsAgendaLoadMore => 'Cargar más elementos de la agenda'; + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'lunes', + 'TU': 'martes', + 'WE': 'miércoles', + 'TH': 'jueves', + 'FR': 'viernes', + 'SA': 'sábado', + 'SU': 'domingo', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'los días $first y $second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency los días $firstDay y $secondDay de $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency el día $day de $firstMonth y $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency los días $firstDay y $secondDay de $firstMonth y $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency los días $days de $firstMonth y $secondMonth'; + } + @override String get appTitle => 'BusyMax'; @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Conecta cuentas de Google, Microsoft, Calendario de Apple iCloud o Nextcloud.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +139,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Añade todas las cuentas que quieras usar. BusyMax sincroniza los calendarios, eventos, listas de tareas y tareas compatibles de cada cuenta.'; @override String get onboardingPreferencesStepTitle => 'Elegir ajustes del sistema'; @@ -119,6 +217,44 @@ class AppLocalizationsEs extends AppLocalizations { @override String get calendars => 'Calendarios'; + @override + String get newCalendar => 'Nuevo calendario'; + + @override + String get calendarColor => 'Color del calendario'; + + @override + String calendarColorOption(int number) { + return 'Opción de color $number'; + } + + @override + String get calendarManagementUnsupported => + 'Este proveedor no admite la gestión de calendarios en BusyMax.'; + + @override + String get primaryCalendarCannotDelete => + 'El calendario principal no se puede eliminar.'; + + @override + String calendarCreateFailed(String error) { + return 'No se pudo crear el calendario: $error'; + } + + @override + String get calendarCreatedRefreshPending => + 'El calendario se creó, pero BusyMax no pudo actualizar la cuenta. Aparecerá después de la próxima sincronización.'; + + @override + String calendarUpdateFailed(String error) { + return 'No se pudo actualizar el calendario: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'No se pudo eliminar el calendario: $error'; + } + @override String get newEvent => 'Nuevo evento'; @@ -142,7 +278,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String moreItems(int count) { - return '+$count más'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString más'; } @override @@ -182,6 +322,101 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trayOpenBusyMax => 'Abrir BusyMax'; + @override + String get trayShowBusyMax => 'Mostrar BusyMax'; + + @override + String get trayNewEvent => 'Evento nuevo…'; + + @override + String get trayNewTask => 'Tarea nueva…'; + + @override + String get trayToday => 'Hoy'; + + @override + String get trayAllDay => 'Todo el día'; + + @override + String get trayNow => 'Ahora'; + + @override + String get trayCalendarEvent => 'Evento del calendario'; + + @override + String get trayUntitledEvent => 'Evento sin título'; + + @override + String get trayNothingElseToday => 'Nada más hoy'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tareas vencen hoy', + one: '1 tarea vence hoy', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Abrir la agenda de hoy'; + + @override + String get traySyncNow => 'Sincronizar ahora'; + + @override + String get traySyncing => 'Sincronizando…'; + + @override + String get trayNotConnected => 'No conectado'; + + @override + String get trayNotYetSynced => 'Aún no sincronizado'; + + @override + String get trayLastSyncedJustNow => 'Sincronizado ahora mismo'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Sincronizado hace $count minutos', + one: 'Sincronizado hace 1 minuto', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Sincronizado hace $count horas', + one: 'Sincronizado hace 1 hora', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Sincronizado hace $count días', + one: 'Sincronizado hace 1 día', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Configuración'; + + @override + String get trayQuitBusyMax => 'Salir de BusyMax'; + @override String get agendaLoadMoreOverdue => 'Cargar más tareas vencidas'; @@ -201,7 +436,7 @@ class AppLocalizationsEs extends AppLocalizations { String get viewYear => 'Año'; @override - String get viewAgenda => 'Agenda'; + String get viewAgenda => 'Vista de agenda'; @override String get scheduleSettings => 'Agenda'; @@ -262,130 +497,130 @@ class AppLocalizationsEs extends AppLocalizations { String get guests => 'Invitados'; @override - String get noGuests => 'Sin invitados'; + String get noGuests => 'No hay invitados'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Obligatorio'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Opcional'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Reunión'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Añadir Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Añadir reunión de Microsoft Teams'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Reunión en línea añadida'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Solicitar respuestas'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'Pide a los invitados que respondan a la invitación.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Ocultar la lista de invitados'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'Los invitados no pueden ver quién más ha sido invitado.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'Permitir nuevas propuestas de horario'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Los invitados pueden sugerir otra hora para la reunión.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => '¿Notificar a los invitados?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'Esta reunión tiene invitados. ¿Enviar invitaciones o actualizaciones del evento al guardarla?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'Esta reunión tiene invitados. ¿Enviar una cancelación al eliminarla?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Enviar actualizaciones'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Enviar cancelación'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'No enviar'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => '¿Guardar la reunión?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft enviará invitaciones o actualizaciones del evento a los invitados.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => '¿Eliminar la reunión?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft enviará una cancelación a los invitados.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Organizador'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'Tu respuesta'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Respuestas de los invitados'; @override - String get respond => 'Respond'; + String get respond => 'Responder'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Aceptar'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Provisional'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Rechazar'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'Unirse a la reunión'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Aceptada'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Provisional'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Rechazada'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'Esperando respuesta'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Sin respuesta'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Organizador'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'No se pudo enviar tu respuesta: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'No se pudo abrir el enlace de la reunión.'; @override String get description => 'Descripción'; @@ -519,7 +754,7 @@ class AppLocalizationsEs extends AppLocalizations { String get keyboardShortcuts => 'Atajos de teclado'; @override - String get shortcutGroupGeneral => 'General'; + String get shortcutGroupGeneral => 'Atajos generales'; @override String get shortcutKeyboardShortcutsDescription => @@ -646,7 +881,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Incluye únicamente la versión del sistema operativo Linux y la configuración regional de la aplicación. No se incluyen registros, datos de cuenta, nombres de archivos ni otros diagnósticos.'; + 'Incluye únicamente el nombre y la versión del sistema operativo y la configuración regional de la aplicación. No se incluyen registros, datos de cuenta, nombres de archivos ni otros diagnósticos.'; @override String get feedbackCategoryRequired => 'Selecciona una categoría.'; @@ -733,12 +968,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String removeAccountTitle(String account) { - return '¿Eliminar $account de BusyMax?'; + return '¿Quitar $account de BusyMax?'; } @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'Esto elimina de este dispositivo las tareas, calendarios, eventos, recordatorios y cambios sin conexión pendientes almacenados en caché. Se perderán los cambios no sincronizados. No se eliminan las copias de calendarios, eventos, listas de tareas ni tareas del proveedor.'; @override String get revokeGoogleAccess => @@ -760,21 +995,21 @@ class AppLocalizationsEs extends AppLocalizations { 'La cuenta se eliminó de este dispositivo, pero BusyMax no pudo revocar su acceso a tu cuenta de Google. Puedes revocarlo desde tu cuenta de Google.'; @override - String get newTaskList => 'Nueva lista de tareas'; + String get newTaskList => 'Lista de tareas nueva'; @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'No se pudo crear la lista de tareas: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'No se pudo cambiar el nombre de la lista de tareas: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'No se pudo eliminar la lista de tareas: $error'; } @override @@ -800,15 +1035,15 @@ class AppLocalizationsEs extends AppLocalizations { String get deleteList => 'Eliminar lista'; @override - String get unshare => 'No compartir'; + String get unshare => 'Dejar de compartir'; @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'Esta lista de tareas es de solo lectura y no se puede cambiar su nombre.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'No se puede eliminar esta lista de tareas con tus permisos actuales.'; @override String get builtInMicrosoftList => 'Integrada'; @@ -819,17 +1054,17 @@ class AppLocalizationsEs extends AppLocalizations { @override String deleteListConfirmation(String title) { - return '¿Eliminar \"$title\" de Google Tasks?'; + return '¿Eliminar «$title» de Google Tasks?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return '¿Eliminar «$title» y todas sus tareas?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return '¿Dejar de compartir «$title» con esta cuenta?'; } @override @@ -954,75 +1189,94 @@ class AppLocalizationsEs extends AppLocalizations { String get openStatus => 'Abierta'; @override - String get doneStatus => 'Completada'; + String get doneStatus => 'Hecha'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'Estado'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Sin estado'; @override String get taskStatusNeedsAction => 'Necesita una acción'; @override - String get taskStatusInProcess => 'En proceso'; + String get taskStatusInProcess => 'En curso'; @override String get taskStatusCompleted => 'Completada'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Cancelada'; @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% completada'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Fecha de finalización'; @override String get priority => 'Prioridad'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Sin prioridad'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioridad $priorityString · Alta'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioridad $priorityString · Media'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioridad $priorityString · Baja'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'URL de la tarea'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => + 'Introduce una URL absoluta, incluido su esquema.'; @override - String get classification => 'Classification'; + String get classification => 'Clasificación'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => + 'Al compartirla, mostrar la tarea completa'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => + 'Al compartirla, mostrar solo la disponibilidad'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'Al compartirla, ocultar esta tarea'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Fijar tarea'; @override String get notes => 'Notas'; @@ -1061,82 +1315,89 @@ class AppLocalizationsEs extends AppLocalizations { String get addReminder => 'Añadir recordatorio'; @override - String get reminders => 'Reminders'; + String get reminders => 'Recordatorios'; @override - String get noReminders => 'Sin recordatorio'; + String get noReminders => 'Sin recordatorios'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Editar recordatorio'; @override - String get beforeTaskStarts => 'Antes de empezar la tarea'; + String get beforeTaskStarts => 'Antes de que empiece la tarea'; @override - String get beforeTaskDue => 'Antes de terminar la tarea'; + String get beforeTaskDue => 'Antes del vencimiento de la tarea'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'Después de que empiece la tarea'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'Después del vencimiento de la tarea'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => + 'En relación con la fecha de inicio de la tarea'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => + 'En relación con la fecha de vencimiento de la tarea'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Hora del día'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'En una fecha y hora'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Cantidad'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Unidad'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Segundos'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'Minutos'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Horas'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Días'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Semanas'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'Al comenzar la tarea'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'Al vencer la tarea'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'Este tipo de recordatorio se conserva, pero no se puede editar su hora.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => + '¿Conservar los recordatorios relacionados?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Esta fecha tiene $countString recordatorios relacionados. ¿Quieres conservarlos en su fecha y hora actuales?'; } @override String get discardRelatedReminders => 'Descartar recordatorios'; @override - String get keepRelatedReminders => 'Mantener recordatorios'; + String get keepRelatedReminders => 'Conservar recordatorios'; @override String get addGuest => 'Añadir invitado'; @@ -1172,101 +1433,145 @@ class AppLocalizationsEs extends AppLocalizations { String get repeatYearly => 'Anual'; @override - String get repeatEvery => 'Repetir cada'; + String get repeatEvery => 'Intervalo'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'Repetir en'; @override String get repeatEnd => 'Finalizar repetición'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'Nunca'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'En una fecha'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'Después de un número de repeticiones'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'Repeticiones'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'Días del mes'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'Meses'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'Posición del día de la semana'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'Días específicos'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'Primero'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'Segundo'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'Tercero'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'Cuarto'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'Quinto'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'Penúltimo'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'Último'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'Día'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'Día laborable'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'Día de fin de semana'; + + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'lunes', + 'TU': 'martes', + 'WE': 'miércoles', + 'TH': 'jueves', + 'FR': 'viernes', + 'SA': 'sábado', + 'SU': 'domingo', + 'day': 'día', + 'weekday': 'día laborable', + 'weekend': 'día de fin de semana', + 'other': '$day', + }); + return '$_temp0'; + } @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Cada $countString días'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Cada $countString semanas'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Cada $countString meses'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Cada $countString años'; } @override String repeatOnDaysSummary(String days) { - return 'dentro de $days'; + return 'los $days'; } @override String repeatOnMonthDaysSummary(String days) { - return 'en día $days'; + return 'el día $days'; } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'el primer $days', + 'second': 'el segundo $days', + 'third': 'el tercer $days', + 'fourth': 'el cuarto $days', + 'fifth': 'el quinto $days', + 'secondToLast': 'el penúltimo $days', + 'last': 'el último $days', + 'other': 'en $days', + }); + return '$_temp0'; } @override @@ -1276,17 +1581,32 @@ class AppLocalizationsEs extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count veces'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString veces', + one: '$countString vez', + ); + return '$_temp0'; } @override String repeatUntilSummary(String date) { - return 'hasta el $date'; + return 'hasta $date'; } @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'Esta regla de repetición usa opciones que este editor no modifica.'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'Esta repetición no se puede usar con $provider.'; + } @override String get importance => 'Importancia'; @@ -1295,7 +1615,7 @@ class AppLocalizationsEs extends AppLocalizations { String get importanceLow => 'Baja'; @override - String get importanceNormal => 'Normal'; + String get importanceNormal => 'Media'; @override String get importanceHigh => 'Alta'; @@ -1344,11 +1664,11 @@ class AppLocalizationsEs extends AppLocalizations { String get duplicateTask => 'Duplicar tarea'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Tarea duplicada.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'No se pudo duplicar la tarea: $error'; } @override @@ -1411,7 +1731,11 @@ class AppLocalizationsEs extends AppLocalizations { String get sync => 'Sincronización'; @override - String get manualFullSync => 'Sincronización completa manual'; + String get forceFullResync => 'Forzar una resincronización completa'; + + @override + String get forceFullResyncDescription => + 'Vuelve a cargar por completo los datos de todas las cuentas conectadas. Esta opción solo debe usarse para solucionar problemas de sincronización.'; @override String get runInBackgroundWhenClosed => @@ -1459,6 +1783,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get eventReminders => 'Recordatorios de eventos'; + @override + String get onState => 'Activado'; + @override String get taskReminders => 'Recordatorios de tareas'; @@ -1470,7 +1797,7 @@ class AppLocalizationsEs extends AppLocalizations { String get notificationDetailPrivate => 'Privado'; @override - String get notificationDetailNormal => 'Normal'; + String get notificationDetailNormal => 'Estándar'; @override String get quietHours => 'Horario de silencio'; @@ -1488,6 +1815,18 @@ class AppLocalizationsEs extends AppLocalizations { @override String get notifications => 'Notificaciones'; + @override + String get windowsNotificationsUnavailable => + 'Las notificaciones de Windows no están disponibles'; + + @override + String get windowsNotificationsUnpackaged => + 'Esta ejecución de desarrollo sin empaquetar no puede usar las notificaciones de Windows. Instala el MSIX firmado para pruebas para comprobar los recordatorios.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax no pudo inicializar las notificaciones de Windows. Los recordatorios no aparecerán hasta que se resuelva este problema de instalación.'; + @override String get appearance => 'Apariencia'; @@ -1497,6 +1836,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get themeSystem => 'Sistema'; + @override + String get settingsSystem => 'Sistema'; + @override String get themeLight => 'Claro'; @@ -1575,7 +1917,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'intentos=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'intentos=$countString'; } @override @@ -1622,7 +1968,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return 'Se bloqueó un cambio local pendiente. $summary'; + return 'Se ha bloqueado un cambio local pendiente. $summary'; } @override @@ -1630,10 +1976,14 @@ class AppLocalizationsEs extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tareas vencen hoy.', + other: '$countString tareas vencen hoy.', one: 'Una tarea vence hoy.', ); return '$_temp0'; @@ -1658,7 +2008,7 @@ class AppLocalizationsEs extends AppLocalizations { String get notificationSnoozeAction => 'Posponer 10 minutos'; @override - String get notificationDismissAction => 'Descartar'; + String get notificationDismissAction => 'Cerrar'; @override String get notificationDetailsHidden => @@ -1711,17 +2061,17 @@ class AppLocalizationsEs extends AppLocalizations { String get searchLocations => 'Buscar ubicaciones'; @override - String get noLocationsFound => 'No se encontraron ubicaciones'; + String get noLocationsFound => 'No se han encontrado ubicaciones'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'Este campo es obligatorio.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Conecta calendarios y tareas de uno de estos proveedores.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Calendario de Apple iCloud'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1730,198 +2080,223 @@ class AppLocalizationsEs extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Tareas de Nextcloud'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => + 'Añadir cuenta del Calendario de Apple iCloud'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Añadir cuenta de Nextcloud'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Conectando con Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Esperando la autorización de Nextcloud…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => + 'Conectar el Calendario de Apple iCloud'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Correo de la cuenta de Apple'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'Contraseña específica de la app'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Crea una contraseña específica de la app después de activar la autenticación de dos factores en tu cuenta de Apple.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Restablecer la contraseña de tu cuenta de Apple revoca las contraseñas específicas de las apps.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Conectar Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Servidor de Nextcloud o dirección CalDAV'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Introduce la URL de tu servidor Nextcloud o pega la dirección CalDAV principal copiada de Nextcloud.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax abrirá el navegador. Aprueba allí el acceso y vuelve a BusyMax.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Conectar'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Cancelar conexión'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'La cuenta se quitó localmente, pero no se pudo revocar la contraseña de app de Nextcloud.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'Los datos del calendario y las tareas se almacenan localmente para usarlos sin conexión.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Vuelve a conectar esta cuenta para reanudar la sincronización.'; @override String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + 'Esta cuenta no está disponible temporalmente.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'Han cambiado los permisos del servidor. Las ediciones pendientes están en pausa.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Este servidor o perfil de proveedor no es compatible.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Calendarios y listas de tareas'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'Eventos del calendario'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Tareas'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Solo lectura'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'Pendiente localmente'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Bloqueado por un conflicto'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'Bloqueado hasta volver a conectar'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'La operación ha fallado'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Conservar la versión del servidor'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'Revisar y volver a aplicar el cambio local'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Duplicar como elemento nuevo'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'Estado de conexión'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Conectado'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'Conectando…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Sesión cerrada'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Última sincronización correcta: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Aún no se ha sincronizado'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'Actualizar calendarios y listas de tareas'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Servidor: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Calendario de eventos'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Lista de tareas'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'Eventos y tareas'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Editable'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Compartido'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Última sincronización: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Problema de sincronización: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Conflictos de sincronización'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Cambio en el servidor: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Edición local: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'No se pudo resolver el conflicto.'; + + @override + String get recurringEventScope => 'Ámbito del evento recurrente'; + + @override + String get entireSeries => 'Serie completa'; + + @override + String get singleOccurrence => 'Este evento'; + + @override + String get thisAndFollowingEvents => 'Este evento y los siguientes'; + + @override + String get thisAndFutureUnavailable => 'No compatible con este proveedor.'; + + @override + String get thisAndFutureMoveUnavailable => + 'No se pueden mover de forma segura este evento y los siguientes. Elige este evento o toda la serie.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get entireSeriesMoveUnavailable => + 'La regla de repetición no está disponible localmente. Mueve solo este evento.'; @override - String get entireSeries => 'Entire series'; + String get copyEventAndDeleteOriginal => + '¿Copiar el evento y eliminar el original?'; @override - String get singleOccurrence => 'This occurrence'; + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax no puede mover este evento directamente de $source a $destination. Primero creará la copia y eliminará el original solo cuando la copia se complete correctamente. Los identificadores del evento cambiarán; los estados de respuesta de los asistentes pueden restablecerse y pueden enviarse invitaciones o cancelaciones; y es posible que no se conserven los enlaces de conferencia, los archivos adjuntos, los recordatorios, los campos específicos del proveedor ni las excepciones de repetición.'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => 'Copiar y eliminar'; @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Elige si este cambio se aplica a toda la serie, solo a este evento o a este y los siguientes eventos.'; @override String get taskDueBeforeStart => @@ -1936,6 +2311,201 @@ class AppLocalizationsEs extends AppLocalizations { return '¿Eliminar \"$title\"?'; } + @override + String get setCustomCalendarName => 'Establecer nombre personalizado'; + + @override + String get setAction => 'Establecer'; + + @override + String get removeFromMyCalendars => 'Quitar de mis calendarios'; + + @override + String get removeAction => 'Quitar'; + + @override + String removeCalendarConfirmation(String title) { + return '¿Quitar \"$title\" de tu lista de Google Calendar? No se eliminarán el calendario compartido ni sus eventos.'; + } + + @override + String get calendarCannotRemove => + 'Este calendario no se puede eliminar ni quitar de esta cuenta.'; + + @override + String get calendarPendingChangesPreventRemoval => + 'Espera a que terminen de sincronizarse los cambios pendientes de este calendario antes de eliminarlo o quitarlo.'; + + @override + String get calendarSubscriptions => 'Suscripciones de calendarios'; + + @override + String get calendarSubscriptionsDescription => + 'Añade calendarios de solo lectura que se actualicen desde una URL WebCal segura.'; + + @override + String get addCalendarSubscription => 'Añadir suscripción de calendario'; + + @override + String get subscriptionName => 'Nombre local'; + + @override + String get subscriptionUrl => 'URL de la suscripción'; + + @override + String get subscriptionUrlHelp => + 'Introduce una URL HTTPS o webcal. BusyMax guarda la URL completa en un almacenamiento seguro.'; + + @override + String get subscriptionUrlInvalid => + 'Introduce una URL HTTPS o webcal válida sin información de usuario ni fragmento.'; + + @override + String get subscriptionColor => 'Color local'; + + @override + String get subscriptionColorHelp => + 'Usa un color de seis dígitos, como #3584E4.'; + + @override + String get subscriptionColorInvalid => + 'Introduce un color hexadecimal de seis dígitos.'; + + @override + String get subscriptionRefreshMode => 'Frecuencia de actualización'; + + @override + String get subscriptionAutomatic => 'Automática'; + + @override + String get subscriptionHourly => 'Cada hora'; + + @override + String get subscriptionSixHours => 'Cada seis horas'; + + @override + String get subscriptionDaily => 'Diaria'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Origen: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Introduce una URL válida para previsualizar su origen seguro.'; + + @override + String get subscriptionReadOnly => 'Suscripción de solo lectura'; + + @override + String get subscriptionNeverRefreshed => 'Aún no se ha actualizado'; + + @override + String subscriptionLastRefresh(String time) { + return 'Última actualización correcta: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Próxima actualización: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Actualizada'; + + @override + String subscriptionStatusIssue(String code) { + return 'Problema de actualización: $code'; + } + + @override + String get refreshNow => 'Actualizar ahora'; + + @override + String get unsubscribe => 'Cancelar suscripción'; + + @override + String unsubscribeCalendarTitle(String name) { + return '¿Cancelar la suscripción a «$name»?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'Esto elimina la suscripción local y sus eventos almacenados en caché. El calendario publicado no cambia.'; + + @override + String get addSubscriptionAction => 'Añadir suscripción'; + + @override + String subscriptionOperationFailed(String error) { + return 'La suscripción al calendario ha fallado: $error'; + } + + @override + String get subscriptions => 'Suscripciones'; + + @override + String get calendarImport => 'Importación de calendario'; + + @override + String get calendarImportDescription => + 'Selecciona un archivo, revisa sus eventos y elige el calendario editable que debe recibirlos.'; + + @override + String get importIcsFile => 'Importar archivo .ics'; + + @override + String get importIcsPreview => 'Importar eventos del calendario'; + + @override + String importEventsFound(int count) { + return 'Conjuntos de eventos importables: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Eventos no válidos: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Omitidos intencionadamente: $fields'; + } + + @override + String get noWritableCalendars => + 'No hay ningún calendario de destino editable disponible.'; + + @override + String get importDestinationCalendar => 'Calendario de destino'; + + @override + String get importIcsConfirm => 'Importar eventos'; + + @override + String get importIcsComplete => 'Importación completada'; + + @override + String importQueued(int count) { + return 'Importados o en cola: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicados omitidos: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Conjuntos de repeticiones no compatibles: $count'; + } + + @override + String importIcsFailed(String error) { + return 'No se pudo importar el archivo de calendario: $error'; + } + @override String get networkOffline => 'Sin conexión'; @@ -1946,4 +2516,139 @@ class AppLocalizationsEs extends AppLocalizations { @override String get networkOfflineTryAgain => 'No tienes conexión. Conéctate a Internet e inténtalo de nuevo.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'los días $days'; + } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(String day) { + return '$day'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first y $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first y $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first y $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first y $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency el día $day de $month'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency los días $days de $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency el día $day de $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency los días $days de $months'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'el primer $days de $month', + 'second': 'el segundo $days de $month', + 'third': 'el tercer $days de $month', + 'fourth': 'el cuarto $days de $month', + 'fifth': 'el quinto $days de $month', + 'secondToLast': 'el penúltimo $days de $month', + 'last': 'el último $days de $month', + 'other': 'en $days de $month', + }); + return '$frequency $_temp0'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'el primer $days de $months', + 'second': 'el segundo $days de $months', + 'third': 'el tercer $days de $months', + 'fourth': 'el cuarto $days de $months', + 'fifth': 'el quinto $days de $months', + 'secondToLast': 'el penúltimo $days de $months', + 'last': 'el último $days de $months', + 'other': 'en $days de $months', + }); + return '$frequency $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 783e6f4a..663c7058 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -12,16 +12,114 @@ import 'app_localizations.dart'; class AppLocalizationsEt extends AppLocalizations { AppLocalizationsEt([String locale = 'et']) : super(locale); + @override + String get windowsSupport => 'Tugi'; + + @override + String get windowsThirdPartyLicenses => 'Kolmandate osapoolte litsentsid'; + + @override + String get windowsSearch => 'Otsi'; + + @override + String get windowsStartupDisabledByUser => + 'Kasutaja keelas selle Windowsi sätetes.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Windowsi poliitika on selle keelanud.'; + + @override + String get windowsStartupUnavailable => + 'Saadaval pärast BusyMaxi installimist MSIX-paketist.'; + + @override + String get windowsReminderExitNotice => + 'BusyMaxi täielikul sulgemisel meeldetuletused peatuvad. Nende saamiseks jätke rakendus taustal tööle.'; + + @override + String get windowsProductVersionLabel => 'Toote versioon'; + + @override + String get windowsPackageVersionLabel => 'Windowsi paketi versioon'; + + @override + String get windowsUnpackaged => 'Pakkimata'; + + @override + String get windowsAgendaLoadMore => 'Laadi rohkem päevakava üksusi'; + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'esmaspäeviti', + 'TU': 'teisipäeviti', + 'WE': 'kolmapäeviti', + 'TH': 'neljapäeviti', + 'FR': 'reedeti', + 'SA': 'laupäeviti', + 'SU': 'pühapäeviti', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return '$first ja $second päeval'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency $month $firstDay ja $secondDay päeval'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency $firstMonth ja $secondMonth $day päeval'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency $firstMonth ja $secondMonth $firstDay ja $secondDay päeval'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency $firstMonth ja $secondMonth $days päeval'; + } + @override String get appTitle => 'BusyMax'; @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Ühendage Google’i, Microsofti, Apple iCloud Calendari või Nextcloudi kontod.'; @override String get googlePermissionsConsentNotice => - 'Valige Google\'i õiguste kuval nii kalendri kui ka ülesannete õigused.'; + 'Valige Google’i õiguste kuval nii kalendri kui ka ülesannete õigused.'; @override String get googlePermissionsRequiredRetry => @@ -41,7 +139,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Lisage kõik kontod, mida soovite kasutada. BusyMax sünkroonib igast kontost toetatud kalendreid, sündmusi, ülesandeloendeid ja ülesandeid.'; @override String get onboardingPreferencesStepTitle => 'Süsteemiseadete valimine'; @@ -51,7 +149,7 @@ class AppLocalizationsEt extends AppLocalizations { 'Enne ajakava avamist määrake töölauakäitumine, meeldetuletused, teavituste üksikasjalikkus ja välimus.'; @override - String get signInWithGoogle => 'Logi Google\'iga sisse'; + String get signInWithGoogle => 'Logi Google’iga sisse'; @override String get signInWithMicrosoft => 'Logi Microsoftiga sisse'; @@ -66,7 +164,7 @@ class AppLocalizationsEt extends AppLocalizations { String get providerNotConfigured => 'See teenusepakkuja pole seadistatud.'; @override - String get waitingForGoogleSignIn => 'Google\'isse sisselogimise ootel...'; + String get waitingForGoogleSignIn => 'Google’isse sisselogimise ootel...'; @override String get waitingForMicrosoftSignIn => 'Microsofti sisselogimise ootel...'; @@ -117,6 +215,43 @@ class AppLocalizationsEt extends AppLocalizations { @override String get calendars => 'Kalendrid'; + @override + String get newCalendar => 'Uus kalender'; + + @override + String get calendarColor => 'Kalendri värv'; + + @override + String calendarColorOption(int number) { + return 'Värv $number'; + } + + @override + String get calendarManagementUnsupported => + 'See teenusepakkuja ei toeta BusyMaxis kalendrite haldamist.'; + + @override + String get primaryCalendarCannotDelete => 'Põhikalendrit ei saa kustutada.'; + + @override + String calendarCreateFailed(String error) { + return 'Kalendri loomine nurjus: $error'; + } + + @override + String get calendarCreatedRefreshPending => + 'Kalender loodi, kuid BusyMax ei saanud kontot värskendada. See ilmub pärast järgmist sünkroonimist.'; + + @override + String calendarUpdateFailed(String error) { + return 'Kalendri värskendamine nurjus: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Kalendri kustutamine nurjus: $error'; + } + @override String get newEvent => 'Uus sündmus'; @@ -181,6 +316,101 @@ class AppLocalizationsEt extends AppLocalizations { @override String get trayOpenBusyMax => 'Ava BusyMax'; + @override + String get trayShowBusyMax => 'Näita BusyMaxi'; + + @override + String get trayNewEvent => 'Uus sündmus…'; + + @override + String get trayNewTask => 'Uus ülesanne…'; + + @override + String get trayToday => 'Täna'; + + @override + String get trayAllDay => 'Kogu päev'; + + @override + String get trayNow => 'Praegu'; + + @override + String get trayCalendarEvent => 'Kalendrisündmus'; + + @override + String get trayUntitledEvent => 'Pealkirjata sündmus'; + + @override + String get trayNothingElseToday => 'Täna pole rohkem midagi'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Täna on tähtajaks $count ülesannet', + one: 'Täna on tähtajaks 1 ülesanne', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Ava tänane päevakava'; + + @override + String get traySyncNow => 'Sünkrooni kohe'; + + @override + String get traySyncing => 'Sünkroonimine…'; + + @override + String get trayNotConnected => 'Pole ühendatud'; + + @override + String get trayNotYetSynced => 'Veel sünkroonimata'; + + @override + String get trayLastSyncedJustNow => 'Viimati sünkrooniti just nüüd'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Viimati sünkrooniti $count minutit tagasi', + one: 'Viimati sünkrooniti 1 minut tagasi', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Viimati sünkrooniti $count tundi tagasi', + one: 'Viimati sünkrooniti 1 tund tagasi', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Viimati sünkrooniti $count päeva tagasi', + one: 'Viimati sünkrooniti 1 päev tagasi', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Seaded'; + + @override + String get trayQuitBusyMax => 'Välju BusyMaxist'; + @override String get agendaLoadMoreOverdue => 'Laadi veel tähtaja ületanud ülesandeid'; @@ -264,127 +494,126 @@ class AppLocalizationsEt extends AppLocalizations { String get noGuests => 'Külalisi pole'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Nõutud'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Valikuline'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Koosolek'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Lisa Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Lisa Microsoft Teamsi koosolek'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Veebikoosolek lisatud'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Küsi vastuseid'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'Paluge külalistel kutsele vastata.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Peida külaliste loend'; @override - String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + String get hideGuestListDescription => 'Külalised ei näe, keda veel kutsuti.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'Luba uusi ajapakkumisi'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Külalised saavad soovitada muud koosolekuaega.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'Kas teavitada külalisi?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'Sellel koosolekul on külalised. Kas saata salvestamisel kutsed või sündmuse uuendused?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'Sellel koosolekul on külalised. Kas saata kustutamisel tühistus?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Saada uuendused'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Saada tühistus'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'Ära saada'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'Kas salvestada koosolek?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft saadab külalistele kutsed või sündmuse uuendused.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'Kas kustutada koosolek?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft saadab külalistele tühistuse.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Korraldaja'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'Sinu vastus'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Külaliste vastused'; @override - String get respond => 'Respond'; + String get respond => 'Vasta'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Nõustu'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Esialgne'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Keeldu'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'Liitu koosolekuga'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Nõustutud'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Esialgne'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Keeldutud'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'Vastust oodatakse'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Vastus puudub'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Korraldaja'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'Sinu vastust ei saanud saata: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'Koosolekulinki ei saanud avada.'; @override String get description => 'Kirjeldus'; @@ -643,7 +872,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Lisatakse ainult teie Linuxi operatsioonisüsteemi versioon ning rakenduse keel ja piirkonnaseaded. Logisid, kontoandmeid, failinimesid ega muid diagnostikaandmeid ei lisata.'; + 'Lisatakse ainult teie operatsioonisüsteemi nimi ja versioon ning rakenduse keel ja piirkonnaseaded. Logisid, kontoandmeid, failinimesid ega muid diagnostikaandmeid ei lisata.'; @override String get feedbackCategoryRequired => 'Valige kategooria.'; @@ -703,7 +932,7 @@ class AppLocalizationsEt extends AppLocalizations { String get switchAccount => 'Vaheta kontot'; @override - String get addGoogleAccount => 'Lisa Google\'i konto'; + String get addGoogleAccount => 'Lisa Google’i konto'; @override String get addMicrosoftAccount => 'Lisa Microsofti konto'; @@ -734,11 +963,11 @@ class AppLocalizationsEt extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'See kustutab sellest seadmest vahemällu salvestatud ülesanded, kalendrid, sündmused, meeldetuletused ja ootel võrguühenduseta muudatused. Sünkroonimata muudatused lähevad kaotsi. Teenusepakkuja kalendrite, sündmuste, ülesandeloendite ja ülesannete koopiaid ei kustutata.'; @override String get revokeGoogleAccess => - 'Tühista ka BusyMaxi juurdepääs sellele Google\'i kontole'; + 'Tühista ka BusyMaxi juurdepääs sellele Google’i kontole'; @override String get revokeGoogleAccessDescription => @@ -753,24 +982,24 @@ class AppLocalizationsEt extends AppLocalizations { @override String get accountRemovedGoogleRevokeFailed => - 'Konto eemaldati sellest seadmest, kuid BusyMaxi juurdepääsu teie Google\'i kontole ei saanud tühistada. Saate selle oma Google\'i kontol käsitsi tühistada.'; + 'Konto eemaldati sellest seadmest, kuid BusyMaxi juurdepääsu teie Google’i kontole ei saanud tühistada. Saate selle oma Google’i kontol käsitsi tühistada.'; @override String get newTaskList => 'Uus ülesandeloend'; @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'Ülesandeloendi loomine ebaõnnestus: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'Ülesandeloendi ümbernimetamine ebaõnnestus: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'Ülesandeloendi kustutamine ebaõnnestus: $error'; } @override @@ -797,15 +1026,15 @@ class AppLocalizationsEt extends AppLocalizations { String get deleteList => 'Kustuta loend'; @override - String get unshare => 'Unshare'; + String get unshare => 'Lõpeta jagamine'; @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'See ülesandeloend on kirjutuskaitstud ja seda ei saa ümber nimetada.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'Seda ülesandeloendit ei saa praeguste õigustega kustutada.'; @override String get builtInMicrosoftList => 'Sisseehitatud'; @@ -821,12 +1050,12 @@ class AppLocalizationsEt extends AppLocalizations { @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return 'Kas kustutada „$title” ja kõik selle ülesanded?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'Kas lõpetada „$title” jagamine selle kontoga?'; } @override @@ -951,75 +1180,91 @@ class AppLocalizationsEt extends AppLocalizations { String get openStatus => 'Pooleli'; @override - String get doneStatus => 'Valmis'; + String get doneStatus => 'Tehtud'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'Olek'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Olek puudub'; @override - String get taskStatusNeedsAction => 'Needs action'; + String get taskStatusNeedsAction => 'Vajab tegevust'; @override - String get taskStatusInProcess => 'In process'; + String get taskStatusInProcess => 'Töös'; @override - String get taskStatusCompleted => 'Completed'; + String get taskStatusCompleted => 'Lõpetatud'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Tühistatud'; @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return 'Lõpetatud $percentString%'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Lõpetamise kuupäev'; @override - String get priority => 'Priority'; + String get priority => 'Prioriteet'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Prioriteet puudub'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioriteet $priorityString · kõrge'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioriteet $priorityString · keskmine'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioriteet $priorityString · madal'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'Ülesande URL'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'Sisestage absoluutne URL koos skeemiga.'; @override - String get classification => 'Classification'; + String get classification => 'Klassifikatsioon'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => 'Jagamisel näita kogu ülesannet'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => 'Jagamisel näita ainult hõivatust'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'Jagamisel peida see ülesanne'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Kinnita ülesanne'; @override String get notes => 'Märkmed'; @@ -1058,82 +1303,86 @@ class AppLocalizationsEt extends AppLocalizations { String get addReminder => 'Lisa meeldetuletus'; @override - String get reminders => 'Reminders'; + String get reminders => 'Meeldetuletused'; @override - String get noReminders => 'No reminders'; + String get noReminders => 'Meeldetuletusi pole'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Muuda meeldetuletust'; @override - String get beforeTaskStarts => 'Before the task starts'; + String get beforeTaskStarts => 'Enne ülesande algust'; @override - String get beforeTaskDue => 'Before the task is due'; + String get beforeTaskDue => 'Enne ülesande tähtaega'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'Pärast ülesande algust'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'Pärast ülesande tähtaega'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'Ülesande alguskuupäeva suhtes'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'Ülesande tähtaja suhtes'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Kellaaeg'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'Kuupäeval ja kellaajal'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Kogus'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Ühik'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Sekundit'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'Minutit'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Tundi'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Päeva'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Nädalat'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'Ülesande alguses'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'Ülesande tähtajal'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'See meeldetuletuse tüüp säilitatakse, kuid selle aega ei saa muuta.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'Kas säilitada seotud meeldetuletused?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Sellel kuupäeval on $countString seotud meeldetuletust. Kas säilitada need praegusel kuupäeval ja kellaajal?'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => 'Hülga meeldetuletused'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => 'Säilita meeldetuletused'; @override String get addGuest => 'Lisa külaline'; @@ -1169,121 +1418,180 @@ class AppLocalizationsEt extends AppLocalizations { String get repeatYearly => 'Iga aasta'; @override - String get repeatEvery => 'Repeat every'; + String get repeatEvery => 'Kordusintervall'; + + @override + String get repeatOn => 'Kordub'; @override - String get repeatOn => 'Repeat on'; + String get repeatEnd => 'Lõpeta kordus'; @override - String get repeatEnd => 'End repeat'; + String get repeatNever => 'Mitte kunagi'; @override - String get repeatNever => 'Never'; + String get repeatUntil => 'Kuupäeval'; @override - String get repeatUntil => 'On date'; + String get repeatAfter => 'Pärast korduste arvu täitumist'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatCount => 'Korduste arv'; @override - String get repeatCount => 'Occurrences'; + String get repeatDayOfMonth => 'Kuu päevad'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatMonths => 'Kuud'; @override - String get repeatMonths => 'Months'; + String get repeatOrdinal => 'Nädalapäeva järjekord'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatSpecificDays => 'Kindlad päevad'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatFirst => 'Esimene'; @override - String get repeatFirst => 'First'; + String get repeatSecond => 'Teine'; @override - String get repeatSecond => 'Second'; + String get repeatThird => 'Kolmas'; @override - String get repeatThird => 'Third'; + String get repeatFourth => 'Neljas'; @override - String get repeatFourth => 'Fourth'; + String get repeatFifth => 'Viies'; @override - String get repeatFifth => 'Fifth'; + String get repeatSecondToLast => 'Eelviimane'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatLast => 'Viimane'; @override - String get repeatLast => 'Last'; + String get repeatAnyDay => 'Päev'; @override - String get repeatAnyDay => 'Day'; + String get repeatWeekday => 'Tööpäev'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekendDay => 'Nädalavahetuse päev'; @override - String get repeatWeekendDay => 'Weekend day'; + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'esmaspäev', + 'TU': 'teisipäev', + 'WE': 'kolmapäev', + 'TH': 'neljapäev', + 'FR': 'reede', + 'SA': 'laupäev', + 'SU': 'pühapäev', + 'day': 'päev', + 'weekday': 'tööpäev', + 'weekend': 'nädalavahetuse päev', + 'other': '$day', + }); + return '$_temp0'; + } @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Iga $countString päeva järel'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Iga $countString nädala järel'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Iga $countString kuu järel'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Iga $countString aasta järel'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return '$days'; } @override String repeatOnMonthDaysSummary(String days) { - return 'on day $days'; + return '$days päeval'; } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'esimene $days', + 'second': 'teine $days', + 'third': 'kolmas $days', + 'fourth': 'neljas $days', + 'fifth': 'viies $days', + 'secondToLast': 'eelviimane $days', + 'last': 'viimane $days', + 'other': '$days', + }); + return '$_temp0'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return '$months'; } @override String repeatTimesSummary(int count) { - return '$count times'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString korda', + one: '$countString kord', + ); + return '$_temp0'; } @override String repeatUntilSummary(String date) { - return 'until $date'; + return 'kuni kuupäevani $date'; } @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'See kordusreegel kasutab valikuid, mida see redaktor ei muuda.'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'Seda kordust ei saa teenusega $provider kasutada.'; + } @override String get importance => 'Tähtsus'; @@ -1338,21 +1646,21 @@ class AppLocalizationsEt extends AppLocalizations { String get subtasks => 'Alamülesanded'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'Loo ülesandest koopia'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Ülesanne dubleeriti.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'Ülesande dubleerimine ebaõnnestus: $error'; } @override - String get hideSubtasks => 'Hide subtasks'; + String get hideSubtasks => 'Peida alamülesanded'; @override - String get hideClosedSubtasks => 'Hide closed subtasks'; + String get hideClosedSubtasks => 'Peida lõpetatud alamülesanded'; @override String get moveToTop => 'Teisalda kõige üles'; @@ -1408,7 +1716,11 @@ class AppLocalizationsEt extends AppLocalizations { String get sync => 'Sünkroonimine'; @override - String get manualFullSync => 'Käsitsi täielik sünkroonimine'; + String get forceFullResync => 'Sunni täielik uuesti sünkroonimine'; + + @override + String get forceFullResyncDescription => + 'Laadib kõigi ühendatud kontode andmed täielikult uuesti. Kasutage seda ainult sünkroonimisprobleemide tõrkeotsinguks.'; @override String get runInBackgroundWhenClosed => @@ -1425,7 +1737,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String get launchAtLoginDescription => - 'Käivita BusyMax taustal, et meeldetuletused töötaksid pärast sisselogimist.'; + 'BusyMax käivitatakse taustal, et meeldetuletused töötaksid pärast sisselogimist.'; @override String get launchAtLoginFailed => @@ -1454,6 +1766,9 @@ class AppLocalizationsEt extends AppLocalizations { @override String get eventReminders => 'Sündmuste meeldetuletused'; + @override + String get onState => 'Sees'; + @override String get taskReminders => 'Ülesannete meeldetuletused'; @@ -1481,6 +1796,18 @@ class AppLocalizationsEt extends AppLocalizations { @override String get notifications => 'Teavitused'; + @override + String get windowsNotificationsUnavailable => + 'Windowsi teavitused pole saadaval'; + + @override + String get windowsNotificationsUnpackaged => + 'See pakendamata arendusversioon ei saa Windowsi teavitusi kasutada. Meeldetuletuste testimiseks paigalda testallkirjastatud MSIX.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax ei saanud Windowsi teavitusi lähtestada. Meeldetuletusi ei kuvata enne, kui see paigaldusprobleem on lahendatud.'; + @override String get appearance => 'Välimus'; @@ -1490,6 +1817,9 @@ class AppLocalizationsEt extends AppLocalizations { @override String get themeSystem => 'Süsteem'; + @override + String get settingsSystem => 'Süsteem'; + @override String get themeLight => 'Hele'; @@ -1648,7 +1978,7 @@ class AppLocalizationsEt extends AppLocalizations { String get notificationSnoozeAction => 'Lükka 10 minutit edasi'; @override - String get notificationDismissAction => 'Loobu'; + String get notificationDismissAction => 'Sulge'; @override String get notificationDetailsHidden => @@ -1704,14 +2034,14 @@ class AppLocalizationsEt extends AppLocalizations { String get noLocationsFound => 'Asukohti ei leitud'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'See väli on kohustuslik.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Ühendage kalendrid ja ülesanded ühega neist teenusepakkujatest.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Apple iCloudi kalender'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1720,211 +2050,428 @@ class AppLocalizationsEt extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Nextcloudi ülesanded'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'Lisa Apple iCloud Calendari konto'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Lisa Nextcloudi konto'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Apple iCloudiga ühendamine…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Nextcloudi autoriseerimise ootel…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Ühenda Apple iCloud Calendar'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Apple’i konto e-post'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'Rakendusepõhine parool'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Loo rakendusepõhine parool pärast Apple’i konto kaheastmelise autentimise lubamist.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Apple’i konto parooli lähtestamine tühistab rakendusepõhised paroolid.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Ühenda Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Nextcloudi server või CalDAV-aadress'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Sisestage Nextcloudi serveri URL või kleepige Nextcloudist kopeeritud peamine CalDAV-aadress.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax avab brauseri. Kinnita juurdepääs seal ja naase seejärel BusyMaxi.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Ühenda'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Tühista ühendus'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'Konto eemaldati kohalikult, kuid Nextcloudi rakenduse parooli ei saanud tühistada.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'Kalendri- ja ülesandeandmed salvestatakse võrguühenduseta kasutamiseks kohapeal.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Sünkroonimise jätkamiseks ühenda see konto uuesti.'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get davTemporarilyUnavailable => 'See konto pole ajutiselt saadaval.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'Serveriõigused muutusid. Ootel muudatused on peatatud.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Seda serverit või teenusepakkuja profiili ei toetata.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Kalendrid ja ülesandeloendid'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'Kalendrisündmused'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Ülesanded'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Kirjutuskaitstud'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'Kohapeal ootel'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Konflikti tõttu blokeeritud'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'Blokeeritud kuni taasühendamiseni'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'Toiming nurjus'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Säilita serveri versioon'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => + 'Vaata kohalik muudatus üle ja rakenda uuesti'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Dubleeri uue üksusena'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'Ühenduse olek'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Ühendatud'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'Ühendamine…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Välja logitud'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Viimane õnnestunud sünkroonimine: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Veel sünkroonimata'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'Värskenda kalendreid ja ülesandeloendeid'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Serveri aadress: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Sündmuste kalender'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Ülesandeloend'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'Sündmused ja ülesanded'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Kirjutatav'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Jagatud'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Viimati sünkroonitud: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Sünkroonimisprobleem: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Sünkroonimiskonfliktid'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Server muutis: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Kohalik muudatus: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'Konflikti ei saanud lahendada.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => 'Korduva sündmuse ulatus'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => 'Kogu sari'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => 'See sündmus'; @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get thisAndFollowingEvents => 'See ja järgmised sündmused'; + + @override + String get thisAndFutureUnavailable => 'See teenusepakkuja ei toeta seda.'; + + @override + String get thisAndFutureMoveUnavailable => + 'Seda ja järgnevaid sündmusi ei saa turvaliselt teisaldada. Valige see sündmus või kogu sari.'; + + @override + String get entireSeriesMoveUnavailable => + 'Kordusreegel pole kohalikult saadaval. Teisalda selle asemel ainult see sündmus.'; + + @override + String get copyEventAndDeleteOriginal => + 'Kas kopeerida sündmus ja kustutada algne?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax ei saa seda sündmust otse kalendrist $source kalendrisse $destination teisaldada. Esmalt luuakse koopia ja algne sündmus kustutatakse alles pärast edukat kopeerimist. Sündmuse ID-d muutuvad; osalejate vastuseolekud võivad lähtestuda ning võidakse saata kutseid või tühistusi; konverentsilingid, manused, meeldetuletused, teenusepakkuja väljad ja korduse erandid ei pruugi üle kanduda.'; + } + + @override + String get copyAndDelete => 'Kopeeri ja kustuta'; @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Valige, kas see muudatus rakendub kogu sarjale, ainult sellele sündmusele või sellele ja järgnevatele sündmustele.'; @override String get taskDueBeforeStart => 'Tähtaeg ei tohi olla enne algust.'; @override String get taskStartDueTimeModeMismatch => - 'Määra nii algus- kui ka tähtaja kellaaeg või tee ülesanne kogu päeva ülesandeks.'; + 'Määrake nii algus- kui ka tähtaja kellaaeg või tehke ülesanne kogu päeva ülesandeks.'; @override String deleteCalendarConfirmation(String title) { return 'Kas kustutada „$title”?'; } + @override + String get setCustomCalendarName => 'Määra kohandatud nimi'; + + @override + String get setAction => 'Määra'; + + @override + String get removeFromMyCalendars => 'Eemalda minu kalendritest'; + + @override + String get removeAction => 'Eemalda'; + + @override + String removeCalendarConfirmation(String title) { + return 'Kas eemaldada „$title” teie Google’i kalendrite loendist? Jagatud kalendrit ega selle sündmusi ei kustutata.'; + } + + @override + String get calendarCannotRemove => + 'Seda kalendrit ei saa sellest kontost kustutada ega eemaldada.'; + + @override + String get calendarPendingChangesPreventRemoval => + 'Enne kalendri kustutamist või eemaldamist oodake, kuni selle ootel muudatused on sünkroonitud.'; + + @override + String get calendarSubscriptions => 'Kalendritellimused'; + + @override + String get calendarSubscriptionsDescription => + 'Lisage kirjutuskaitstud kalendreid, mida värskendatakse turvalise WebCali URL-i kaudu.'; + + @override + String get addCalendarSubscription => 'Lisa kalendritellimus'; + + @override + String get subscriptionName => 'Kohalik nimi'; + + @override + String get subscriptionUrl => 'Tellimuse URL'; + + @override + String get subscriptionUrlHelp => + 'Sisestage HTTPS- või webcal-URL. BusyMax hoiab täielikku URL-i turvalises salvestusruumis.'; + + @override + String get subscriptionUrlInvalid => + 'Sisestage kehtiv HTTPS- või webcal-URL ilma kasutajateabe või fragmendita.'; + + @override + String get subscriptionColor => 'Kohalik värv'; + + @override + String get subscriptionColorHelp => + 'Kasutage kuuekohalist värvi, näiteks #3584E4.'; + + @override + String get subscriptionColorInvalid => + 'Sisestage kuuekohaline kuueteistkümnendsüsteemi värv.'; + + @override + String get subscriptionRefreshMode => 'Värskendussagedus'; + + @override + String get subscriptionAutomatic => 'Automaatne'; + + @override + String get subscriptionHourly => 'Iga tunni järel'; + + @override + String get subscriptionSixHours => 'Iga kuue tunni järel'; + + @override + String get subscriptionDaily => 'Iga päev'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Allikas: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Turvalise allika eelvaateks sisesta kehtiv URL.'; + + @override + String get subscriptionReadOnly => 'Kirjutuskaitstud tellimus'; + + @override + String get subscriptionNeverRefreshed => 'Veel värskendamata'; + + @override + String subscriptionLastRefresh(String time) { + return 'Viimane edukas värskendus: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Järgmine värskendus: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Ajaga kaasas'; + + @override + String subscriptionStatusIssue(String code) { + return 'Värskendusprobleem: $code'; + } + + @override + String get refreshNow => 'Värskenda kohe'; + + @override + String get unsubscribe => 'Tühista tellimus'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Kas tühistada „$name” tellimus?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'See eemaldab kohaliku tellimuse ja selle vahemällu salvestatud sündmused. Avaldatud kalendrit ei muudeta.'; + + @override + String get addSubscriptionAction => 'Lisa tellimus'; + + @override + String subscriptionOperationFailed(String error) { + return 'Kalendritellimus nurjus: $error'; + } + + @override + String get subscriptions => 'Tellimused'; + + @override + String get calendarImport => 'Kalendri import'; + + @override + String get calendarImportDescription => + 'Valige fail, vaadake selle sündmused üle ja valige seejärel kirjutatav kalender, kuhu need lisada.'; + + @override + String get importIcsFile => 'Impordi .ics-fail'; + + @override + String get importIcsPreview => 'Impordi kalendrisündmused'; + + @override + String importEventsFound(int count) { + return 'Imporditavad sündmusekomplektid: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Sobimatud sündmused: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Tahtlikult välja jäetud: $fields'; + } + + @override + String get noWritableCalendars => 'Sobivat kirjutatavat sihtkalendrit pole.'; + + @override + String get importDestinationCalendar => 'Sihtkalender'; + + @override + String get importIcsConfirm => 'Impordi sündmused'; + + @override + String get importIcsComplete => 'Import lõpetatud'; + + @override + String importQueued(int count) { + return 'Imporditud või järjekorda lisatud: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Vahele jäetud duplikaadid: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Toetamata korduskomplektid: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Kalendrifaili ei saanud importida: $error'; + } + @override String get networkOffline => 'Võrguühenduseta'; @@ -1935,4 +2482,153 @@ class AppLocalizationsEt extends AppLocalizations { @override String get networkOfflineTryAgain => 'Võrguühendus puudub. Looge internetiühendus ja proovige uuesti.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return '$days päeval'; + } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(String day) { + return '$day.'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first ja $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first ja $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, { + 'jan': 'jaanuaris', + 'feb': 'veebruaris', + 'mar': 'märtsis', + 'apr': 'aprillis', + 'may': 'mais', + 'jun': 'juunis', + 'jul': 'juulis', + 'aug': 'augustis', + 'sep': 'septembris', + 'oct': 'oktoobris', + 'nov': 'novembris', + 'dec': 'detsembris', + 'other': '$month', + }); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first ja $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first ja $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency $month $day päeval'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency $month $days päeval'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency $months $day päeval'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency $months $days päeval'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'esimene', + 'second': 'teine', + 'third': 'kolmas', + 'fourth': 'neljas', + 'fifth': 'viies', + 'secondToLast': 'eelviimane', + 'last': 'viimane', + 'other': '', + }); + return '$frequency $month: $_temp0 $days'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'esimene', + 'second': 'teine', + 'third': 'kolmas', + 'fourth': 'neljas', + 'fifth': 'viies', + 'secondToLast': 'eelviimane', + 'last': 'viimane', + 'other': '', + }); + return '$frequency $months: $_temp0 $days'; + } } diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 43bd2f52..da5511eb 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -12,12 +12,110 @@ import 'app_localizations.dart'; class AppLocalizationsFa extends AppLocalizations { AppLocalizationsFa([String locale = 'fa']) : super(locale); + @override + String get windowsSupport => 'پشتیبانی'; + + @override + String get windowsThirdPartyLicenses => 'مجوزهای شخص ثالث'; + + @override + String get windowsSearch => 'جستجو'; + + @override + String get windowsStartupDisabledByUser => + 'کاربر این گزینه را در تنظیمات Windows غیرفعال کرده است.'; + + @override + String get windowsStartupDisabledByPolicy => + 'با خط‌مشی Windows غیرفعال شده است.'; + + @override + String get windowsStartupUnavailable => + 'پس از نصب BusyMax از بستهٔ MSIX در دسترس است.'; + + @override + String get windowsReminderExitNotice => + 'با خروج کامل از BusyMax یادآورها متوقف می‌شوند. برای دریافت آن‌ها برنامه را در پس‌زمینه فعال نگه دارید.'; + + @override + String get windowsProductVersionLabel => 'نسخهٔ محصول'; + + @override + String get windowsPackageVersionLabel => 'نسخهٔ بستهٔ Windows'; + + @override + String get windowsUnpackaged => 'بدون بسته'; + + @override + String get windowsAgendaLoadMore => 'بارگیری موارد بیشتر دستور کار'; + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'دوشنبه', + 'TU': 'سه‌شنبه', + 'WE': 'چهارشنبه', + 'TH': 'پنجشنبه', + 'FR': 'جمعه', + 'SA': 'شنبه', + 'SU': 'یکشنبه', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'در روزهای $first و $second ماه'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency در روزهای $firstDay و $secondDay ماه $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency در روز $day ماه‌های $firstMonth و $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency در روزهای $firstDay و $secondDay ماه‌های $firstMonth و $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency در روزهای $days ماه‌های $firstMonth و $secondMonth'; + } + @override String get appTitle => 'BusyMax'; @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'حساب‌های Google، Microsoft، Apple iCloud Calendar یا Nextcloud را متصل کنید.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +139,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'همهٔ حساب‌هایی را که می‌خواهید استفاده کنید اضافه کنید. BusyMax تقویم‌ها، رویدادها، فهرست‌های کار و کارهای پشتیبانی‌شده را از هر حساب همگام می‌کند.'; @override String get onboardingPreferencesStepTitle => 'انتخاب تنظیمات سیستم'; @@ -117,6 +215,47 @@ class AppLocalizationsFa extends AppLocalizations { @override String get calendars => 'تقویم‌ها'; + @override + String get newCalendar => 'تقویم جدید'; + + @override + String get calendarColor => 'رنگ تقویم'; + + @override + String calendarColorOption(int number) { + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'رنگ $numberString'; + } + + @override + String get calendarManagementUnsupported => + 'این ارائه‌دهنده از مدیریت تقویم در BusyMax پشتیبانی نمی‌کند.'; + + @override + String get primaryCalendarCannotDelete => 'تقویم اصلی را نمی‌توان حذف کرد.'; + + @override + String calendarCreateFailed(String error) { + return 'ایجاد تقویم ممکن نشد: ⁨$error⁩'; + } + + @override + String get calendarCreatedRefreshPending => + 'تقویم ایجاد شد، اما BusyMax نتوانست حساب را تازه‌سازی کند. پس از همگام‌سازی بعدی نمایش داده می‌شود.'; + + @override + String calendarUpdateFailed(String error) { + return 'به‌روزرسانی تقویم ممکن نشد: ⁨$error⁩'; + } + + @override + String calendarDeleteFailed(String error) { + return 'حذف تقویم ممکن نشد: ⁨$error⁩'; + } + @override String get newEvent => 'رویداد جدید'; @@ -184,6 +323,117 @@ class AppLocalizationsFa extends AppLocalizations { @override String get trayOpenBusyMax => 'باز کردن BusyMax'; + @override + String get trayShowBusyMax => 'نمایش BusyMax'; + + @override + String get trayNewEvent => 'رویداد جدید…'; + + @override + String get trayNewTask => 'کار جدید…'; + + @override + String get trayToday => 'امروز'; + + @override + String get trayAllDay => 'تمام‌روز'; + + @override + String get trayNow => 'اکنون'; + + @override + String get trayCalendarEvent => 'رویداد تقویم'; + + @override + String get trayUntitledEvent => 'رویداد بدون عنوان'; + + @override + String get trayNothingElseToday => 'امروز مورد دیگری نیست'; + + @override + String trayTasksDueToday(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString کار امروز سررسید دارند', + one: '۱ کار امروز سررسید دارد', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'باز کردن برنامهٔ امروز'; + + @override + String get traySyncNow => 'همگام‌سازی اکنون'; + + @override + String get traySyncing => 'در حال همگام‌سازی…'; + + @override + String get trayNotConnected => 'متصل نیست'; + + @override + String get trayNotYetSynced => 'هنوز همگام نشده'; + + @override + String get trayLastSyncedJustNow => 'همین الان همگام شد'; + + @override + String trayLastSyncedMinutesAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString دقیقه پیش همگام شد', + one: '۱ دقیقه پیش همگام شد', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString ساعت پیش همگام شد', + one: '۱ ساعت پیش همگام شد', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString روز پیش همگام شد', + one: '۱ روز پیش همگام شد', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'تنظیمات'; + + @override + String get trayQuitBusyMax => 'خروج از BusyMax'; + @override String get agendaLoadMoreOverdue => 'بارگیری کارهای عقب‌افتادهٔ بیشتر'; @@ -264,130 +514,130 @@ class AppLocalizationsFa extends AppLocalizations { String get guests => 'مهمانان'; @override - String get noGuests => 'بدون مهمان'; + String get noGuests => 'فهرست مهمانان خالی است'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'ضروری'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'اختیاری'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'جلسه'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'افزودن Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'افزودن جلسهٔ Microsoft Teams'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'جلسهٔ آنلاین افزوده شد'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'درخواست پاسخ'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'از مهمانان بخواهید به دعوت‌نامه پاسخ دهند.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'پنهان کردن فهرست مهمانان'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'مهمانان نمی‌توانند ببینند چه افراد دیگری دعوت شده‌اند.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'اجازهٔ پیشنهاد زمان‌های جدید'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'مهمانان می‌توانند زمان دیگری برای جلسه پیشنهاد کنند.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'مهمانان اطلاع داده شوند؟'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'این جلسه مهمان دارد. هنگام ذخیره، دعوت‌نامه یا به‌روزرسانی رویداد برای آن‌ها ارسال شود؟'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'این جلسه مهمان دارد. هنگام حذف، لغو جلسه ارسال شود؟'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'ارسال به‌روزرسانی‌ها'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'ارسال لغو'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'ارسال نکن'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'جلسه ذخیره شود؟'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft دعوت‌نامه یا به‌روزرسانی رویداد را برای مهمانان ارسال می‌کند.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'جلسه حذف شود؟'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft لغو جلسه را برای مهمانان ارسال می‌کند.'; @override - String get organizer => 'Organizer'; + String get organizer => 'برگزارکننده'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'پاسخ شما'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'پاسخ مهمانان'; @override - String get respond => 'Respond'; + String get respond => 'پاسخ دادن'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'پذیرفتن'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'موقت'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'رد کردن'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'پیوستن به جلسه'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'پذیرفته‌شده'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'موقت'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'ردشده'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'در انتظار پاسخ'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'بی‌پاسخ'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'برگزارکننده'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'ارسال پاسخ شما ممکن نیست: ⁨$error⁩'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'باز کردن پیوند جلسه ممکن نیست.'; @override String get description => 'توضیحات'; @@ -521,7 +771,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String tasksInList(String title) { - return 'کارهای ⁨$title⁩'; + return 'کارهای ⁨⁨$title⁩⁩'; } @override @@ -662,7 +912,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'فقط نسخهٔ سیستم‌عامل Linux و تنظیمات منطقه‌ای برنامه افزوده می‌شود. هیچ گزارش، دادهٔ حساب، نام پرونده یا اطلاعات تشخیصی دیگری افزوده نمی‌شود.'; + 'فقط نام و نسخهٔ سیستم‌عامل و تنظیمات منطقه‌ای برنامه افزوده می‌شود. هیچ گزارش، دادهٔ حساب، نام پرونده یا اطلاعات تشخیصی دیگری افزوده نمی‌شود.'; @override String get feedbackCategoryRequired => 'یک دسته‌بندی انتخاب کنید.'; @@ -700,7 +950,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String feedbackSuccess(String id) { - return 'بازخورد ارسال شد. شناسهٔ پیگیری: ⁨$id⁩'; + return 'بازخورد ارسال شد. شناسهٔ پیگیری: ⁨⁨$id⁩⁩'; } @override @@ -748,12 +998,12 @@ class AppLocalizationsFa extends AppLocalizations { @override String removeAccountTitle(String account) { - return 'حذف ⁨$account⁩ از BusyMax؟'; + return '⁨⁨$account⁩⁩ از BusyMax حذف شود؟'; } @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'این کار، کارها، تقویم‌ها، رویدادها، یادآورها و تغییرات آفلاین در انتظار را که در این دستگاه ذخیره شده‌اند حذف می‌کند. تغییرات همگام‌نشده از بین می‌روند. نسخه‌های تقویم‌ها، رویدادها، فهرست‌های کار و کارها نزد ارائه‌دهنده حذف نمی‌شوند.'; @override String get revokeGoogleAccess => @@ -778,17 +1028,17 @@ class AppLocalizationsFa extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'ایجاد فهرست کار ممکن نیست: ⁨$error⁩'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'تغییر نام فهرست کار ممکن نیست: ⁨$error⁩'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'حذف فهرست کار ممکن نیست: ⁨$error⁩'; } @override @@ -817,11 +1067,11 @@ class AppLocalizationsFa extends AppLocalizations { @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'این فهرست کار فقط‌خواندنی است و نمی‌توان نام آن را تغییر داد.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'این فهرست کار با مجوزهای فعلی شما قابل حذف نیست.'; @override String get builtInMicrosoftList => 'داخلی'; @@ -832,17 +1082,17 @@ class AppLocalizationsFa extends AppLocalizations { @override String deleteListConfirmation(String title) { - return '«⁨$title⁩» از Google Tasks حذف شود؟'; + return '‏«⁨⁨$title⁩⁩» از Google Tasks حذف شود؟'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return '‏«⁨$title⁩» و همهٔ کارهای آن حذف شوند؟'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'اشتراک‌گذاری «⁨$title⁩» از این حساب لغو شود؟'; } @override @@ -874,17 +1124,17 @@ class AppLocalizationsFa extends AppLocalizations { @override String exportedFile(String path) { - return 'در ⁨$path⁩ خروجی گرفته شد'; + return 'در ⁨⁨$path⁩⁩ خروجی گرفته شد'; } @override String exportFailed(String error) { - return 'خروجی گرفتن ناموفق بود: ⁨$error⁩'; + return 'خروجی گرفتن ناموفق بود: ⁨⁨$error⁩⁩'; } @override String refreshFailed(String error) { - return 'تازه‌سازی ناموفق بود: ⁨$error⁩'; + return 'تازه‌سازی ناموفق بود: ⁨⁨$error⁩⁩'; } @override @@ -927,12 +1177,12 @@ class AppLocalizationsFa extends AppLocalizations { @override String duePrefix(String date) { - return 'سررسید: ⁨$date⁩'; + return 'سررسید: ⁨⁨$date⁩⁩'; } @override String dateTimeDisplay(String date, String time) { - return '⁨$date⁩ · ⁨$time⁩'; + return '⁨⁨$date⁩⁩ · ⁨⁨$time⁩⁩'; } @override @@ -970,13 +1220,13 @@ class AppLocalizationsFa extends AppLocalizations { String get doneStatus => 'انجام‌شده'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'وضعیت'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'بدون وضعیت'; @override - String get taskStatusNeedsAction => 'نیاز به اقدام'; + String get taskStatusNeedsAction => 'نیازمند اقدام'; @override String get taskStatusInProcess => 'در حال انجام'; @@ -985,57 +1235,74 @@ class AppLocalizationsFa extends AppLocalizations { String get taskStatusCompleted => 'انجام‌شده'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'لغوشده'; @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString٪ تکمیل‌شده'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'تاریخ تکمیل'; @override String get priority => 'اولویت'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'بدون اولویت'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'اولویت $priorityString · زیاد'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'اولویت $priorityString · متوسط'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'اولویت $priorityString · کم'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'URL کار'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'یک URL مطلق شامل طرح آن وارد کنید.'; @override - String get classification => 'Classification'; + String get classification => 'دسته‌بندی'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => 'هنگام اشتراک‌گذاری، کل کار نشان داده شود'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => + 'هنگام اشتراک‌گذاری، فقط مشغول بودن نشان داده شود'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'هنگام اشتراک‌گذاری، این کار پنهان شود'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'سنجاق کردن کار'; @override String get notes => 'یادداشت‌ها'; @@ -1074,82 +1341,86 @@ class AppLocalizationsFa extends AppLocalizations { String get addReminder => 'افزودن یادآور'; @override - String get reminders => 'Reminders'; + String get reminders => 'یادآورها'; @override - String get noReminders => 'بدون یادآوری'; + String get noReminders => 'بدون یادآور'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'ویرایش یادآور'; @override - String get beforeTaskStarts => 'قبل از شروع کار'; + String get beforeTaskStarts => 'پیش از شروع کار'; @override - String get beforeTaskDue => 'قبل از موعد انجام کار'; + String get beforeTaskDue => 'پیش از سررسید کار'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'پس از شروع کار'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'پس از سررسید کار'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'نسبت به تاریخ شروع کار'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'نسبت به تاریخ سررسید کار'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'زمان روز'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'در تاریخ و زمان مشخص'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'مقدار'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'واحد'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'ثانیه'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'دقیقه'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'ساعت'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'روز'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'هفته'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'هنگام شروع کار'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'هنگام سررسید کار'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'نوع این یادآور حفظ می‌شود، اما زمان آن قابل ویرایش نیست.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'یادآورهای مرتبط نگه داشته شوند؟'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'این تاریخ $countString یادآور مرتبط دارد. آن‌ها در تاریخ و زمان فعلی نگه داشته شوند؟'; } @override - String get discardRelatedReminders => 'حذف یادآوری‌ها'; + String get discardRelatedReminders => 'دور انداختن یادآورها'; @override - String get keepRelatedReminders => 'نگه‌داشتن یادآوری‌ها'; + String get keepRelatedReminders => 'نگه‌داشتن یادآورها'; @override String get addGuest => 'افزودن مهمان'; @@ -1185,86 +1456,120 @@ class AppLocalizationsFa extends AppLocalizations { String get repeatYearly => 'سالانه'; @override - String get repeatEvery => 'تکرار هر'; + String get repeatEvery => 'فاصلهٔ تکرار'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'تکرار در'; @override String get repeatEnd => 'پایان تکرار'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'هرگز'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'در تاریخ'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'پس از تعداد مشخصی تکرار'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'تعداد تکرار'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'روزهای ماه'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'ماه‌ها'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'جایگاه روز هفته'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'روزهای مشخص'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'اول'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'دوم'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'سوم'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'چهارم'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'پنجم'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'یکی مانده به آخر'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'آخر'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'روز'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'روز هفته'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'روز آخر هفته'; + + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'دوشنبه', + 'TU': 'سه‌شنبه', + 'WE': 'چهارشنبه', + 'TH': 'پنجشنبه', + 'FR': 'جمعه', + 'SA': 'شنبه', + 'SU': 'یکشنبه', + 'day': 'روز', + 'weekday': 'روز هفته', + 'weekend': 'روز آخر هفته', + 'other': '$day', + }); + return '$_temp0'; + } @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'هر $countString روز'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'هر $countString هفته'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'هر $countString ماه'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'هر $countString سال'; } @override @@ -1274,12 +1579,22 @@ class AppLocalizationsFa extends AppLocalizations { @override String repeatOnMonthDaysSummary(String days) { - return 'در روز $days'; + return 'در روز $days ماه'; } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'در اولین $days', + 'second': 'در دومین $days', + 'third': 'در سومین $days', + 'fourth': 'در چهارمین $days', + 'fifth': 'در پنجمین $days', + 'secondToLast': 'در یکی‌مانده‌به‌آخرین $days', + 'last': 'در آخرین $days', + 'other': 'در $days', + }); + return '$_temp0'; } @override @@ -1289,17 +1604,32 @@ class AppLocalizationsFa extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count بار'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString بار', + one: '$countString بار', + ); + return '$_temp0'; } @override String repeatUntilSummary(String date) { - return 'تا $date'; + return 'تا ⁨$date⁩'; } @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'این قانون تکرار از گزینه‌هایی استفاده می‌کند که این ویرایشگر تغییر نمی‌دهد.'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'این تکرار را نمی‌توان با ⁨$provider⁩ استفاده کرد.'; + } @override String get importance => 'اهمیت'; @@ -1354,21 +1684,21 @@ class AppLocalizationsFa extends AppLocalizations { String get subtasks => 'زیرکارها'; @override - String get duplicateTask => 'تکراری‌سازی وظیفه'; + String get duplicateTask => 'تکرار کار'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'کار تکرار شد.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'تکرار کار ممکن نیست: ⁨$error⁩'; } @override - String get hideSubtasks => 'مخفی‌سازی زیروظیفه‌ها'; + String get hideSubtasks => 'پنهان کردن زیروظیفه‌ها'; @override - String get hideClosedSubtasks => 'مخفی‌سازی زیروظیفه‌های بسته‌شده'; + String get hideClosedSubtasks => 'پنهان کردن زیروظیفه‌های بسته‌شده'; @override String get moveToTop => 'انتقال به بالاترین جایگاه'; @@ -1381,7 +1711,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String deleteTaskConfirmation(String title) { - return '«⁨$title⁩» حذف شود؟'; + return '«⁨⁨$title⁩⁩» حذف شود؟'; } @override @@ -1424,7 +1754,11 @@ class AppLocalizationsFa extends AppLocalizations { String get sync => 'همگام‌سازی'; @override - String get manualFullSync => 'همگام‌سازی کامل دستی'; + String get forceFullResync => 'اجبار به همگام‌سازی مجدد کامل'; + + @override + String get forceFullResyncDescription => + 'همهٔ داده‌های هر حساب متصل را به‌طور کامل دوباره بارگیری می‌کند. فقط برای عیب‌یابی مشکلات همگام‌سازی از این گزینه استفاده کنید.'; @override String get runInBackgroundWhenClosed => 'ادامهٔ اجرا پس از بسته شدن پنجره'; @@ -1453,7 +1787,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String syncFailed(String error) { - return 'همگام‌سازی ناموفق بود: ⁨$error⁩'; + return 'همگام‌سازی ناموفق بود: ⁨⁨$error⁩⁩'; } @override @@ -1468,6 +1802,9 @@ class AppLocalizationsFa extends AppLocalizations { @override String get eventReminders => 'یادآورهای رویداد'; + @override + String get onState => 'روشن'; + @override String get taskReminders => 'یادآورهای کار'; @@ -1496,6 +1833,18 @@ class AppLocalizationsFa extends AppLocalizations { @override String get notifications => 'اعلان‌ها'; + @override + String get windowsNotificationsUnavailable => + 'اعلان‌های Windows در دسترس نیستند'; + + @override + String get windowsNotificationsUnpackaged => + 'این اجرای توسعه‌ای بسته‌بندی‌نشده نمی‌تواند از اعلان‌های Windows استفاده کند. برای آزمایش یادآورها، MSIX امضاشدهٔ آزمایشی را نصب کنید.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax نتوانست اعلان‌های Windows را راه‌اندازی کند. تا برطرف شدن این مشکل نصب، یادآورها نمایش داده نمی‌شوند.'; + @override String get appearance => 'ظاهر'; @@ -1505,6 +1854,9 @@ class AppLocalizationsFa extends AppLocalizations { @override String get themeSystem => 'سیستم'; + @override + String get settingsSystem => 'سیستم'; + @override String get themeLight => 'روشن'; @@ -1544,7 +1896,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String discoveryRevision(String revision) { - return 'بازبینی Discovery: ⁨$revision⁩'; + return 'بازبینی Discovery: ⁨⁨$revision⁩⁩'; } @override @@ -1573,12 +1925,12 @@ class AppLocalizationsFa extends AppLocalizations { @override String pendingOpListId(String id) { - return 'فهرست=⁨$id⁩'; + return 'فهرست=⁨⁨$id⁩⁩'; } @override String pendingOpTaskId(String id) { - return 'کار=⁨$id⁩'; + return 'کار=⁨⁨$id⁩⁩'; } @override @@ -1624,7 +1976,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String syncFailureNotificationBody(String message) { - return 'همگام‌سازی پس‌زمینه ناموفق بود. ⁨$message⁩'; + return 'همگام‌سازی پس‌زمینه ناموفق بود. ⁨⁨$message⁩⁩'; } @override @@ -1632,7 +1984,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return 'یک تغییر محلی در انتظار مسدود شد. ⁨$summary⁩'; + return 'یک تغییر محلی در انتظار مسدود شد. ⁨⁨$summary⁩⁩'; } @override @@ -1673,7 +2025,7 @@ class AppLocalizationsFa extends AppLocalizations { String get notificationSnoozeAction => 'تعویق ۱۰ دقیقه‌ای'; @override - String get notificationDismissAction => 'رد کردن'; + String get notificationDismissAction => 'بستن'; @override String get notificationDetailsHidden => @@ -1738,14 +2090,14 @@ class AppLocalizationsFa extends AppLocalizations { String get noLocationsFound => 'مکانی پیدا نشد'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'این فیلد الزامی است.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'تقویم‌ها و کارها را از یکی از این ارائه‌دهندگان متصل کنید.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'تقویم Apple iCloud'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1754,198 +2106,220 @@ class AppLocalizationsFa extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'کارهای Nextcloud'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'افزودن حساب تقویم Apple iCloud'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'افزودن حساب Nextcloud'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'در حال اتصال به Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'در انتظار تأیید Nextcloud…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'اتصال تقویم Apple iCloud'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'ایمیل حساب Apple'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'گذرواژهٔ اختصاصی برنامه'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'پس از فعال کردن احراز هویت دومرحله‌ای برای حساب Apple، یک گذرواژهٔ اختصاصی برنامه بسازید.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'بازنشانی گذرواژهٔ حساب Apple، گذرواژه‌های اختصاصی برنامه را باطل می‌کند.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'اتصال Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'سرور Nextcloud یا نشانی CalDAV'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'URL سرور Nextcloud خود را وارد کنید یا نشانی اصلی CalDAV را که از Nextcloud کپی کرده‌اید بچسبانید.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax مرورگر شما را باز می‌کند. آنجا دسترسی را تأیید کنید و سپس به BusyMax برگردید.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'اتصال'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'لغو اتصال'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'حساب به‌صورت محلی حذف شد، اما ابطال گذرواژهٔ برنامهٔ Nextcloud ممکن نیست.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'داده‌های تقویم و کار برای استفادهٔ آفلاین به‌صورت محلی ذخیره می‌شوند.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'برای ادامهٔ همگام‌سازی، این حساب را دوباره متصل کنید.'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get davTemporarilyUnavailable => 'این حساب موقتاً در دسترس نیست.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'مجوزهای سرور تغییر کرده‌اند. ویرایش‌های در انتظار متوقف شده‌اند.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'این سرور یا نمایهٔ ارائه‌دهنده پشتیبانی نمی‌شود.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'تقویم‌ها و فهرست‌های کار'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'رویدادهای تقویم'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'کارها'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'فقط‌خواندنی'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'در انتظار محلی'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'مسدودشده به‌دلیل تعارض'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'تا اتصال دوباره مسدود است'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'عملیات ناموفق بود'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'نگه‌داشتن نسخهٔ سرور'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'بررسی و اعمال دوبارهٔ تغییر محلی'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'تکرار به‌عنوان مورد جدید'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'وضعیت اتصال'; @override - String get davConnected => 'Connected'; + String get davConnected => 'متصل'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'در حال اتصال…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'خارج‌شده'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'آخرین همگام‌سازی موفق: ⁨$time⁩'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'هنوز همگام نشده'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'تازه‌سازی تقویم‌ها و فهرست‌های کار'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'سرور: ⁨$host⁩'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'تقویم رویدادها'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'فهرست کار'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'رویدادها و کارها'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'قابل نوشتن'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'اشتراکی'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'آخرین همگام‌سازی: ⁨$time⁩'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'مشکل همگام‌سازی: ⁨$code⁩'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'تعارض‌های همگام‌سازی'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'تغییر سرور: ⁨$time⁩'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'ویرایش محلی: ⁨$summary⁩'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'حل تعارض ممکن نیست.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => 'دامنهٔ رویداد تکرارشونده'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => 'کل مجموعه'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => 'این رویداد'; @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get thisAndFollowingEvents => 'این رویداد و رویدادهای بعدی'; + + @override + String get thisAndFutureUnavailable => + 'این ارائه‌دهنده از آن پشتیبانی نمی‌کند.'; + + @override + String get thisAndFutureMoveUnavailable => + 'انتقال امن این رویداد و رویدادهای بعدی ممکن نیست. این رویداد یا کل مجموعه را انتخاب کنید.'; + + @override + String get entireSeriesMoveUnavailable => + 'قاعدهٔ تکرار به‌صورت محلی در دسترس نیست. به‌جای آن فقط این رویداد را منتقل کنید.'; + + @override + String get copyEventAndDeleteOriginal => 'رویداد کپی و نسخهٔ اصلی حذف شود؟'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax نمی‌تواند این رویداد را مستقیماً از ⁨$source⁩ به ⁨$destination⁩ منتقل کند. ابتدا کپی را ایجاد می‌کند و فقط پس از موفقیت کپی، نسخهٔ اصلی را حذف می‌کند. شناسه‌های رویداد تغییر می‌کنند؛ ممکن است وضعیت پاسخ شرکت‌کنندگان بازنشانی شود و دعوت‌نامه یا لغو ارسال شود؛ و شاید پیوندهای جلسه، پیوست‌ها، یادآورها، فیلدهای ویژهٔ ارائه‌دهنده و استثناهای تکرار منتقل نشوند.'; + } + + @override + String get copyAndDelete => 'کپی و حذف'; @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'انتخاب کنید این تغییر برای کل مجموعه، فقط این رویداد، یا این رویداد و رویدادهای بعدی اعمال شود.'; @override String get taskDueBeforeStart => 'زمان سررسید نباید پیش از زمان شروع باشد.'; @@ -1956,7 +2330,219 @@ class AppLocalizationsFa extends AppLocalizations { @override String deleteCalendarConfirmation(String title) { - return '«⁨$title⁩» حذف شود؟'; + return '«⁨⁨$title⁩⁩» حذف شود؟'; + } + + @override + String get setCustomCalendarName => 'تنظیم نام سفارشی'; + + @override + String get setAction => 'تنظیم'; + + @override + String get removeFromMyCalendars => 'حذف از تقویم‌های من'; + + @override + String get removeAction => 'حذف'; + + @override + String removeCalendarConfirmation(String title) { + return '«⁨$title⁩» از فهرست تقویم Google شما حذف شود؟ تقویم اشتراکی و رویدادهای آن حذف نخواهند شد.'; + } + + @override + String get calendarCannotRemove => + 'این تقویم را نمی‌توان از این حساب حذف یا جدا کرد.'; + + @override + String get calendarPendingChangesPreventRemoval => + 'پیش از حذف یا جدا کردن این تقویم، صبر کنید تغییرات در انتظار آن همگام شوند.'; + + @override + String get calendarSubscriptions => 'اشتراک‌های تقویم'; + + @override + String get calendarSubscriptionsDescription => + 'تقویم‌های فقط‌خواندنی را اضافه کنید که از یک URL امن WebCal تازه می‌شوند.'; + + @override + String get addCalendarSubscription => 'افزودن اشتراک تقویم'; + + @override + String get subscriptionName => 'نام محلی'; + + @override + String get subscriptionUrl => 'URL اشتراک'; + + @override + String get subscriptionUrlHelp => + 'یک URL از نوع HTTPS یا webcal وارد کنید. BusyMax URL کامل را در فضای ذخیره‌سازی امن نگه می‌دارد.'; + + @override + String get subscriptionUrlInvalid => + 'یک URL معتبر HTTPS یا webcal بدون اطلاعات کاربر یا قطعه وارد کنید.'; + + @override + String get subscriptionColor => 'رنگ محلی'; + + @override + String get subscriptionColorHelp => 'یک رنگ شش‌رقمی مانند #3584E4 وارد کنید.'; + + @override + String get subscriptionColorInvalid => 'یک رنگ هگزادسیمال شش‌رقمی وارد کنید.'; + + @override + String get subscriptionRefreshMode => 'دفعات تازه‌سازی'; + + @override + String get subscriptionAutomatic => 'خودکار'; + + @override + String get subscriptionHourly => 'ساعتی'; + + @override + String get subscriptionSixHours => 'هر شش ساعت'; + + @override + String get subscriptionDaily => 'روزانه'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'مبدأ: ⁨$origin⁩'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'برای پیش‌نمایش مبدأ امن، یک URL معتبر وارد کنید.'; + + @override + String get subscriptionReadOnly => 'اشتراک فقط‌خواندنی'; + + @override + String get subscriptionNeverRefreshed => 'هنوز تازه نشده'; + + @override + String subscriptionLastRefresh(String time) { + return 'آخرین تازه‌سازی موفق: ⁨$time⁩'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'تازه‌سازی بعدی: ⁨$time⁩'; + } + + @override + String get subscriptionStatusHealthy => 'به‌روز'; + + @override + String subscriptionStatusIssue(String code) { + return 'مشکل تازه‌سازی: ⁨$code⁩'; + } + + @override + String get refreshNow => 'تازه‌سازی اکنون'; + + @override + String get unsubscribe => 'لغو اشتراک'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'اشتراک «⁨$name⁩» لغو شود؟'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'این کار اشتراک محلی و رویدادهای ذخیره‌شدهٔ آن را حذف می‌کند. تقویم منتشرشده تغییر نمی‌کند.'; + + @override + String get addSubscriptionAction => 'افزودن اشتراک'; + + @override + String subscriptionOperationFailed(String error) { + return 'اشتراک تقویم ناموفق بود: ⁨$error⁩'; + } + + @override + String get subscriptions => 'اشتراک‌ها'; + + @override + String get calendarImport => 'درون‌ریزی تقویم'; + + @override + String get calendarImportDescription => + 'یک فایل انتخاب کنید، رویدادهای آن را بررسی کنید و سپس تقویم قابل نوشتنی را که باید آن‌ها را دریافت کند انتخاب کنید.'; + + @override + String get importIcsFile => 'درون‌ریزی فایل .ics'; + + @override + String get importIcsPreview => 'درون‌ریزی رویدادهای تقویم'; + + @override + String importEventsFound(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'مجموعه رویدادهای قابل درون‌ریزی: $countString'; + } + + @override + String importInvalidEvents(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'رویدادهای نامعتبر: $countString'; + } + + @override + String importFieldsOmitted(String fields) { + return 'عمداً حذف‌شده: ⁨$fields⁩'; + } + + @override + String get noWritableCalendars => 'تقویم مقصد قابل نوشتنی موجود نیست.'; + + @override + String get importDestinationCalendar => 'تقویم مقصد'; + + @override + String get importIcsConfirm => 'درون‌ریزی رویدادها'; + + @override + String get importIcsComplete => 'درون‌ریزی کامل شد'; + + @override + String importQueued(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'درون‌ریزی‌شده یا در صف: $countString'; + } + + @override + String importDuplicatesSkipped(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'تکراری‌ها رد شدند: $countString'; + } + + @override + String importUnsupportedSets(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'مجموعه‌های تکرار پشتیبانی‌نشده: $countString'; + } + + @override + String importIcsFailed(String error) { + return 'درون‌ریزی فایل تقویم ممکن نیست: ⁨$error⁩'; } @override @@ -1969,4 +2555,139 @@ class AppLocalizationsFa extends AppLocalizations { @override String get networkOfflineTryAgain => 'آفلاین هستید. به اینترنت متصل شوید و دوباره تلاش کنید.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'در روزهای $days ماه'; + } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(String day) { + return '$day'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first و $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first، $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first و $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first، $rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first و $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first، $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first و $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first، $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency در روز $day ماه $month'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency در روزهای $days ماه $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency در روز $day ماه‌های $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency در روزهای $days ماه‌های $months'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'اولین', + 'second': 'دومین', + 'third': 'سومین', + 'fourth': 'چهارمین', + 'fifth': 'پنجمین', + 'secondToLast': 'یکی‌مانده‌به‌آخرین', + 'last': 'آخرین', + 'other': '', + }); + return '$frequency در $_temp0 $days ماه $month'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'اولین', + 'second': 'دومین', + 'third': 'سومین', + 'fourth': 'چهارمین', + 'fifth': 'پنجمین', + 'secondToLast': 'یکی‌مانده‌به‌آخرین', + 'last': 'آخرین', + 'other': '', + }); + return '$frequency در $_temp0 $days ماه‌های $months'; + } } diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index c31d0646..118c5b3c 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -12,12 +12,110 @@ import 'app_localizations.dart'; class AppLocalizationsFi extends AppLocalizations { AppLocalizationsFi([String locale = 'fi']) : super(locale); + @override + String get windowsSupport => 'Tuki'; + + @override + String get windowsThirdPartyLicenses => 'Kolmannen osapuolen lisenssit'; + + @override + String get windowsSearch => 'Haku'; + + @override + String get windowsStartupDisabledByUser => + 'Käyttäjä on poistanut toiminnon käytöstä Windowsin asetuksissa.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Windows-käytäntö on poistanut toiminnon käytöstä.'; + + @override + String get windowsStartupUnavailable => + 'Käytettävissä, kun BusyMax on asennettu MSIX-paketista.'; + + @override + String get windowsReminderExitNotice => + 'Muistutukset lakkaavat, kun BusyMax suljetaan kokonaan. Jätä se toimimaan taustalle saadaksesi muistutukset.'; + + @override + String get windowsProductVersionLabel => 'Tuoteversio'; + + @override + String get windowsPackageVersionLabel => 'Windows-paketin versio'; + + @override + String get windowsUnpackaged => 'Pakkaamaton'; + + @override + String get windowsAgendaLoadMore => 'Lataa lisää esityslistan kohteita'; + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'maanantaisin', + 'TU': 'tiistaisin', + 'WE': 'keskiviikkoisin', + 'TH': 'torstaisin', + 'FR': 'perjantaisin', + 'SA': 'lauantaisin', + 'SU': 'sunnuntaisin', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'kuukauden $first ja $second päivänä'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency $month $firstDay ja $secondDay päivänä'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency $firstMonth ja $secondMonth $day päivänä'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency $firstMonth ja $secondMonth $firstDay ja $secondDay päivänä'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency $firstMonth ja $secondMonth $days päivänä'; + } + @override String get appTitle => 'BusyMax'; @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Yhdistä Google-, Microsoft-, Apple iCloud Calendar- tai Nextcloud-tilit.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +139,7 @@ class AppLocalizationsFi extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Lisää kaikki tilit, joita haluat käyttää. BusyMax synkronoi tuetut kalenterit, tapahtumat, tehtäväluettelot ja tehtävät jokaiselta tililtä.'; @override String get onboardingPreferencesStepTitle => 'Valitse järjestelmäasetukset'; @@ -119,6 +217,44 @@ class AppLocalizationsFi extends AppLocalizations { @override String get calendars => 'Kalenterit'; + @override + String get newCalendar => 'Uusi kalenteri'; + + @override + String get calendarColor => 'Kalenterin väri'; + + @override + String calendarColorOption(int number) { + return 'Väri $number'; + } + + @override + String get calendarManagementUnsupported => + 'Tämä palveluntarjoaja ei tue kalenterien hallintaa BusyMaxissa.'; + + @override + String get primaryCalendarCannotDelete => + 'Ensisijaista kalenteria ei voi poistaa.'; + + @override + String calendarCreateFailed(String error) { + return 'Kalenteria ei voitu luoda: $error'; + } + + @override + String get calendarCreatedRefreshPending => + 'Kalenteri luotiin, mutta BusyMax ei voinut päivittää tiliä. Se tulee näkyviin seuraavan synkronoinnin jälkeen.'; + + @override + String calendarUpdateFailed(String error) { + return 'Kalenteria ei voitu päivittää: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Kalenteria ei voitu poistaa: $error'; + } + @override String get newEvent => 'Uusi tapahtuma'; @@ -142,7 +278,17 @@ class AppLocalizationsFi extends AppLocalizations { @override String moreItems(int count) { - return '+$count muuta'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '+$countString muuta', + one: '+1 muu', + ); + return '$_temp0'; } @override @@ -182,6 +328,101 @@ class AppLocalizationsFi extends AppLocalizations { @override String get trayOpenBusyMax => 'Avaa BusyMax'; + @override + String get trayShowBusyMax => 'Näytä BusyMax'; + + @override + String get trayNewEvent => 'Uusi tapahtuma…'; + + @override + String get trayNewTask => 'Uusi tehtävä…'; + + @override + String get trayToday => 'Tänään'; + + @override + String get trayAllDay => 'Koko päivä'; + + @override + String get trayNow => 'Nyt'; + + @override + String get trayCalendarEvent => 'Kalenteritapahtuma'; + + @override + String get trayUntitledEvent => 'Nimetön tapahtuma'; + + @override + String get trayNothingElseToday => 'Ei muuta tänään'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tehtävää erääntyy tänään', + one: '1 tehtävä erääntyy tänään', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Avaa tämän päivän agenda'; + + @override + String get traySyncNow => 'Synkronoi nyt'; + + @override + String get traySyncing => 'Synkronoidaan…'; + + @override + String get trayNotConnected => 'Ei yhteyttä'; + + @override + String get trayNotYetSynced => 'Ei vielä synkronoitu'; + + @override + String get trayLastSyncedJustNow => 'Synkronoitu juuri nyt'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Synkronoitu $count minuuttia sitten', + one: 'Synkronoitu minuutti sitten', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Synkronoitu $count tuntia sitten', + one: 'Synkronoitu tunti sitten', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Synkronoitu $count päivää sitten', + one: 'Synkronoitu päivä sitten', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Asetukset'; + + @override + String get trayQuitBusyMax => 'Lopeta BusyMax'; + @override String get agendaLoadMoreOverdue => 'Lataa lisää myöhässä olevia tehtäviä'; @@ -201,7 +442,7 @@ class AppLocalizationsFi extends AppLocalizations { String get viewYear => 'Vuosi'; @override - String get viewAgenda => 'Agenda'; + String get viewAgenda => 'Agendanäkymä'; @override String get scheduleSettings => 'Aikataulu'; @@ -265,127 +506,127 @@ class AppLocalizationsFi extends AppLocalizations { String get noGuests => 'Ei vieraita'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Pakollinen'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Valinnainen'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Kokous'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Lisää Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Lisää Microsoft Teams -kokous'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Verkkokokous lisätty'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Pyydä vastauksia'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'Pyydä vieraita vastaamaan kutsuun.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Piilota vierasluettelo'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'Vieraat eivät näe, keitä muita on kutsuttu.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'Salli uudet aikaehdotukset'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Vieraat voivat ehdottaa toista kokousaikaa.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'Ilmoitetaanko vieraille?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'Tässä kokouksessa on vieraita. Lähetetäänkö kutsut tai tapahtumapäivitykset tallennettaessa?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'Tässä kokouksessa on vieraita. Lähetetäänkö peruutus, kun kokous poistetaan?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Lähetä päivitykset'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Lähetä peruutus'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'Älä lähetä'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'Tallennetaanko kokous?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft lähettää vieraille kutsut tai tapahtumapäivitykset.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'Poistetaanko kokous?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft lähettää vieraille peruutuksen.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Järjestäjä'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'Vastauksesi'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Vieraiden vastaukset'; @override - String get respond => 'Respond'; + String get respond => 'Vastaa'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Hyväksy'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Alustava'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Hylkää'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'Liity kokoukseen'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Hyväksytty'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Alustava'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Hylätty'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'Vastausta odotetaan'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Ei vastausta'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Järjestäjä'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'Vastauksesi lähetys epäonnistui: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'Kokouslinkkiä ei voitu avata.'; @override String get description => 'Kuvaus'; @@ -431,10 +672,14 @@ class AppLocalizationsFi extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes minuuttia ennen', + other: '$minutesString minuuttia ennen', one: '1 minuutti ennen', ); return '$_temp0'; @@ -445,10 +690,14 @@ class AppLocalizationsFi extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours tuntia ennen', + other: '$hoursString tuntia ennen', one: '1 tunti ennen', ); return '$_temp0'; @@ -456,10 +705,15 @@ class AppLocalizationsFi extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days päivää ennen', + other: '$daysString päivää ennen', one: '1 päivä ennen', ); return '$_temp0'; @@ -646,7 +900,7 @@ class AppLocalizationsFi extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Lisää vain Linux-käyttöjärjestelmäsi version ja sovelluksen alueasetuksen. Lokeja, tilitietoja, tiedostonimiä tai muita diagnostiikkatietoja ei lisätä.'; + 'Lisää vain käyttöjärjestelmäsi nimen ja version sekä sovelluksen alueasetuksen. Lokeja, tilitietoja, tiedostonimiä tai muita diagnostiikkatietoja ei lisätä.'; @override String get feedbackCategoryRequired => 'Valitse luokka.'; @@ -737,7 +991,7 @@ class AppLocalizationsFi extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'Tämä poistaa laitteelta välimuistiin tallennetut tehtävät, kalenterit, tapahtumat, muistutukset ja odottavat offline-muutokset. Synkronoimattomat muutokset menetetään. Palveluntarjoajan kalenteri-, tapahtuma-, tehtäväluettelo- ja tehtäväkopioita ei poisteta.'; @override String get revokeGoogleAccess => @@ -763,17 +1017,17 @@ class AppLocalizationsFi extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'Tehtäväluettelon luominen epäonnistui: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'Tehtäväluettelon uudelleennimeäminen epäonnistui: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'Tehtäväluettelon poistaminen epäonnistui: $error'; } @override @@ -804,11 +1058,11 @@ class AppLocalizationsFi extends AppLocalizations { @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'Tämä tehtäväluettelo on vain luku -tilassa, eikä sitä voi nimetä uudelleen.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'Tätä tehtäväluetteloa ei voi poistaa nykyisillä käyttöoikeuksillasi.'; @override String get builtInMicrosoftList => 'Sisäänrakennettu'; @@ -819,17 +1073,17 @@ class AppLocalizationsFi extends AppLocalizations { @override String deleteListConfirmation(String title) { - return 'Poistetaanko \"$title\" Google Tasksista?'; + return 'Poistetaanko ”$title” Google Tasksista?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return 'Poistetaanko ”$title” ja kaikki sen tehtävät?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'Lopetetaanko kohteen ”$title” jakaminen tämän tilin kanssa?'; } @override @@ -957,10 +1211,10 @@ class AppLocalizationsFi extends AppLocalizations { String get doneStatus => 'Valmis'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'Tila'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Ei tilaa'; @override String get taskStatusNeedsAction => 'Vaatii toimenpiteitä'; @@ -969,60 +1223,76 @@ class AppLocalizationsFi extends AppLocalizations { String get taskStatusInProcess => 'Käsittelyssä'; @override - String get taskStatusCompleted => 'Valmiina'; + String get taskStatusCompleted => 'Valmis'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Peruutettu'; @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return 'Valmis $percentString %'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Valmistumispäivä'; @override String get priority => 'Tärkeys'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Ei tärkeyttä'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Tärkeys $priorityString · korkea'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Tärkeys $priorityString · keskitaso'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Tärkeys $priorityString · matala'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'Tehtävän URL'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'Anna absoluuttinen URL-osoite skeemoineen.'; @override - String get classification => 'Classification'; + String get classification => 'Luokittelu'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => 'Näytä koko tehtävä jaettaessa'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => 'Näytä jaettaessa vain varattu-aika'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'Piilota tämä tehtävä jaettaessa'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Kiinnitä tehtävä'; @override String get notes => 'Muistiinpanot'; @@ -1061,82 +1331,86 @@ class AppLocalizationsFi extends AppLocalizations { String get addReminder => 'Lisää muistutus'; @override - String get reminders => 'Reminders'; + String get reminders => 'Muistutukset'; @override - String get noReminders => 'No reminders'; + String get noReminders => 'Ei muistutuksia'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Muokkaa muistutusta'; @override - String get beforeTaskStarts => 'Before the task starts'; + String get beforeTaskStarts => 'Ennen tehtävän alkamista'; @override - String get beforeTaskDue => 'Before the task is due'; + String get beforeTaskDue => 'Ennen tehtävän määräaikaa'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'Tehtävän alkamisen jälkeen'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'Tehtävän määräajan jälkeen'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'Suhteessa tehtävän aloituspäivään'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'Suhteessa tehtävän määräpäivään'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Kellonaika'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'Tiettynä päivänä ja kellonaikana'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Määrä'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Yksikkö'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Sekunnit'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'Minuutit'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Tunnit'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Päivät'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Viikot'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'Tehtävän alkaessa'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'Tehtävän määräaikana'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'Tämä muistutustyyppi säilytetään, mutta sen aikaa ei voi muokata.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'Säilytetäänkö liittyvät muistutukset?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Tällä päivämäärällä on $countString liittyvää muistutusta. Säilytetäänkö ne nykyisessä päivämäärässä ja kellonajassa?'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => 'Hylkää muistutukset'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => 'Säilytä muistutukset'; @override String get addGuest => 'Lisää vieras'; @@ -1172,111 +1446,165 @@ class AppLocalizationsFi extends AppLocalizations { String get repeatYearly => 'Vuosittain'; @override - String get repeatEvery => 'Toista joka'; + String get repeatEvery => 'Toistoväli'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'Toistopäivät'; @override String get repeatEnd => 'Lopeta toisto'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'Ei koskaan'; + + @override + String get repeatUntil => 'Päivämääränä'; @override - String get repeatUntil => 'On date'; + String get repeatAfter => 'Toistokertojen jälkeen'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatCount => 'Toistokerrat'; @override - String get repeatCount => 'Occurrences'; + String get repeatDayOfMonth => 'Kuukauden päivät'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatMonths => 'Kuukaudet'; @override - String get repeatMonths => 'Months'; + String get repeatOrdinal => 'Viikonpäivän järjestys'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatSpecificDays => 'Tietyt päivät'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatFirst => 'Ensimmäinen'; @override - String get repeatFirst => 'First'; + String get repeatSecond => 'Toinen'; @override - String get repeatSecond => 'Second'; + String get repeatThird => 'Kolmas'; @override - String get repeatThird => 'Third'; + String get repeatFourth => 'Neljäs'; @override - String get repeatFourth => 'Fourth'; + String get repeatFifth => 'Viides'; @override - String get repeatFifth => 'Fifth'; + String get repeatSecondToLast => 'Toiseksi viimeinen'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatLast => 'Viimeinen'; @override - String get repeatLast => 'Last'; + String get repeatAnyDay => 'Päivä'; @override - String get repeatAnyDay => 'Day'; + String get repeatWeekday => 'Arkipäivä'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekendDay => 'Viikonlopun päivä'; @override - String get repeatWeekendDay => 'Weekend day'; + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'maanantaina', + 'TU': 'tiistaina', + 'WE': 'keskiviikkona', + 'TH': 'torstaina', + 'FR': 'perjantaina', + 'SA': 'lauantaina', + 'SU': 'sunnuntaina', + 'day': 'päivänä', + 'weekday': 'arkipäivänä', + 'weekend': 'viikonlopun päivänä', + 'other': '$day', + }); + return '$_temp0'; + } @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Joka $countString. päivä'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Joka $countString. viikko'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Joka $countString. kuukausi'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Joka $countString. vuosi'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return '$days'; } @override String repeatOnMonthDaysSummary(String days) { - return 'päivänä $days'; + return 'kuukauden $days päivänä'; } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'ensimmäisenä $days', + 'second': 'toisena $days', + 'third': 'kolmantena $days', + 'fourth': 'neljäntenä $days', + 'fifth': 'viidentenä $days', + 'secondToLast': 'toiseksi viimeisenä $days', + 'last': 'viimeisenä $days', + 'other': 'päivinä $days', + }); + return '$_temp0'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return '$months aikana'; } @override String repeatTimesSummary(int count) { - return '$count kertaa'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString kertaa', + one: '$countString kerran', + ); + return '$_temp0'; } @override @@ -1286,7 +1614,12 @@ class AppLocalizationsFi extends AppLocalizations { @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'Tämä toistosääntö käyttää asetuksia, joita tämä muokkain ei muuta.'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'Tätä toistoa ei voi käyttää palvelussa $provider.'; + } @override String get importance => 'Tärkeys'; @@ -1341,14 +1674,14 @@ class AppLocalizationsFi extends AppLocalizations { String get subtasks => 'Alitehtävät'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'Monista tehtävä'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Tehtävä monistettiin.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'Tehtävän monistaminen epäonnistui: $error'; } @override @@ -1411,7 +1744,11 @@ class AppLocalizationsFi extends AppLocalizations { String get sync => 'Synkronointi'; @override - String get manualFullSync => 'Manuaalinen täysi synkronointi'; + String get forceFullResync => 'Pakota täydellinen uudelleensynkronointi'; + + @override + String get forceFullResyncDescription => + 'Lataa kaikkien yhdistettyjen tilien tiedot kokonaan uudelleen. Käytä tätä vain synkronointiongelmien vianmääritykseen.'; @override String get runInBackgroundWhenClosed => @@ -1457,6 +1794,9 @@ class AppLocalizationsFi extends AppLocalizations { @override String get eventReminders => 'Tapahtumamuistutukset'; + @override + String get onState => 'Käytössä'; + @override String get taskReminders => 'Tehtävämuistutukset'; @@ -1485,6 +1825,18 @@ class AppLocalizationsFi extends AppLocalizations { @override String get notifications => 'Ilmoitukset'; + @override + String get windowsNotificationsUnavailable => + 'Windows-ilmoitukset eivät ole käytettävissä'; + + @override + String get windowsNotificationsUnpackaged => + 'Tämä paketoimaton kehitysajo ei voi käyttää Windows-ilmoituksia. Testaa muistutukset asentamalla testivarmenteella allekirjoitettu MSIX.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax ei voinut alustaa Windows-ilmoituksia. Muistutuksia ei näytetä, ennen kuin tämä asennusongelma on ratkaistu.'; + @override String get appearance => 'Ulkoasu'; @@ -1494,6 +1846,9 @@ class AppLocalizationsFi extends AppLocalizations { @override String get themeSystem => 'Järjestelmä'; + @override + String get settingsSystem => 'Järjestelmä'; + @override String get themeLight => 'Vaalea'; @@ -1571,7 +1926,11 @@ class AppLocalizationsFi extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'yritykset=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'yritykset=$countString'; } @override @@ -1625,10 +1984,14 @@ class AppLocalizationsFi extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tehtävää erääntyy tänään.', + other: '$countString tehtävää erääntyy tänään.', one: 'Yksi tehtävä erääntyy tänään.', ); return '$_temp0'; @@ -1653,7 +2016,7 @@ class AppLocalizationsFi extends AppLocalizations { String get notificationSnoozeAction => 'Torkuta 10 minuuttia'; @override - String get notificationDismissAction => 'Hylkää'; + String get notificationDismissAction => 'Sulje'; @override String get notificationDetailsHidden => @@ -1679,7 +2042,11 @@ class AppLocalizationsFi extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'Viikko $number'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Viikko $numberString'; } @override @@ -1687,10 +2054,14 @@ class AppLocalizationsFi extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count kohdetta', + other: '$countString kohdetta', one: '1 kohde', ); return '$_temp0'; @@ -1709,14 +2080,14 @@ class AppLocalizationsFi extends AppLocalizations { String get noLocationsFound => 'Sijainteja ei löytynyt'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'Tämä kenttä on pakollinen.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Yhdistä kalenterit ja tehtävät johonkin näistä palveluntarjoajista.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Apple iCloud -kalenteri'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1725,198 +2096,223 @@ class AppLocalizationsFi extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Nextcloud-tehtävät'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'Lisää Apple iCloud -kalenteritili'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Lisää Nextcloud-tili'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Yhdistetään Apple iCloudiin…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Odotetaan Nextcloud-valtuutusta…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Yhdistä Apple iCloud -kalenteri'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Apple-tilin sähköposti'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'Sovelluskohtainen salasana'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Luo sovelluskohtainen salasana, kun olet ottanut kaksivaiheisen todennuksen käyttöön Apple-tililläsi.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Apple-tilin salasanan vaihtaminen mitätöi sovelluskohtaiset salasanat.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Yhdistä Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Nextcloud-palvelin tai CalDAV-osoite'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Anna Nextcloud-palvelimesi URL-osoite tai liitä Nextcloudista kopioitu ensisijainen CalDAV-osoite.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax avaa selaimesi. Hyväksy käyttö siellä ja palaa sitten BusyMaxiin.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Yhdistä'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Peruuta yhteys'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'Tili poistettiin paikallisesti, mutta Nextcloudin sovellussalasanaa ei voitu kumota.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'Kalenteri- ja tehtävätiedot tallennetaan paikallisesti offline-käyttöä varten.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Yhdistä tämä tili uudelleen jatkaaksesi synkronointia.'; @override String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + 'Tämä tili ei ole tilapäisesti käytettävissä.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'Palvelimen käyttöoikeudet muuttuivat. Odottavat muokkaukset on keskeytetty.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Tätä palvelinta tai palveluntarjoajan profiilia ei tueta.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Kalenterit ja tehtäväluettelot'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'Kalenteritapahtumat'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Tehtävät'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Vain luku'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'Paikallisesti odottava'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Ristiriidan estämä'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => + 'Estetty, kunnes yhteys muodostetaan uudelleen'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'Toiminto epäonnistui'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Säilytä palvelinversio'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => + 'Tarkista ja ota paikallinen muutos uudelleen käyttöön'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Monista uudeksi kohteeksi'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'Yhteyden tila'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Yhdistetty'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'Yhdistetään…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Kirjautunut ulos'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Viimeisin onnistunut synkronointi: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Ei vielä synkronoitu'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'Päivitä kalenterit ja tehtäväluettelot'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Palvelin: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Tapahtumakalenteri'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Tehtäväluettelo'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'Tapahtumat ja tehtävät'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Muokattava'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Jaettu'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Viimeksi synkronoitu: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Synkronointiongelma: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Synkronointiristiriidat'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Palvelin muuttui: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Paikallinen muokkaus: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'Ristiriitaa ei voitu ratkaista.'; + + @override + String get recurringEventScope => 'Toistuvan tapahtuman laajuus'; + + @override + String get entireSeries => 'Koko sarja'; @override - String get recurringEventScope => 'Recurring event scope'; + String get singleOccurrence => 'Tämä tapahtuma'; @override - String get entireSeries => 'Entire series'; + String get thisAndFollowingEvents => 'Tämä ja seuraavat tapahtumat'; @override - String get singleOccurrence => 'This occurrence'; + String get thisAndFutureUnavailable => 'Tämä palveluntarjoaja ei tue tätä.'; @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get thisAndFutureMoveUnavailable => + 'Tätä ja seuraavia tapahtumia ei voi siirtää turvallisesti. Valitse tämä tapahtuma tai koko sarja.'; + + @override + String get entireSeriesMoveUnavailable => + 'Toistumissääntö ei ole saatavilla paikallisesti. Siirrä sen sijaan vain tämä tapahtuma.'; + + @override + String get copyEventAndDeleteOriginal => + 'Kopioidaanko tapahtuma ja poistetaanko alkuperäinen?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax ei voi siirtää tätä tapahtumaa suoraan kalenterista $source kalenteriin $destination. Kopio luodaan ensin, ja alkuperäinen poistetaan vasta onnistuneen kopioinnin jälkeen. Tapahtuman tunnisteet muuttuvat; osallistujien vastaustilat voivat nollautua ja kutsuja tai peruutuksia voidaan lähettää; kokouslinkit, liitteet, muistutukset, palveluntarjoajakohtaiset kentät ja toistumisen poikkeukset eivät välttämättä siirry.'; + } + + @override + String get copyAndDelete => 'Kopioi ja poista'; @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Valitse, koskeeko tämä muutos koko sarjaa, vain tätä tapahtumaa vai tätä ja seuraavia tapahtumia.'; @override String get taskDueBeforeStart => 'Määräaika ei saa olla ennen alkamisaikaa.'; @@ -1930,6 +2326,201 @@ class AppLocalizationsFi extends AppLocalizations { return 'Poistetaanko \"$title\"?'; } + @override + String get setCustomCalendarName => 'Aseta mukautettu nimi'; + + @override + String get setAction => 'Aseta'; + + @override + String get removeFromMyCalendars => 'Poista omista kalentereistani'; + + @override + String get removeAction => 'Poista'; + + @override + String removeCalendarConfirmation(String title) { + return 'Poistetaanko ”$title” Google Kalenterin luettelostasi? Jaettua kalenteria tai sen tapahtumia ei poisteta.'; + } + + @override + String get calendarCannotRemove => + 'Tätä kalenteria ei voi poistaa tai irrottaa tästä tilistä.'; + + @override + String get calendarPendingChangesPreventRemoval => + 'Odota tämän kalenterin odottavien muutosten synkronointia ennen poistamista tai irrottamista.'; + + @override + String get calendarSubscriptions => 'Kalenteritilaukset'; + + @override + String get calendarSubscriptionsDescription => + 'Lisää vain luku -kalentereita, jotka päivittyvät suojatusta WebCal-URL-osoitteesta.'; + + @override + String get addCalendarSubscription => 'Lisää kalenteritilaus'; + + @override + String get subscriptionName => 'Paikallinen nimi'; + + @override + String get subscriptionUrl => 'Tilauksen URL'; + + @override + String get subscriptionUrlHelp => + 'Anna HTTPS- tai webcal-URL. BusyMax säilyttää täydellisen URL-osoitteen suojatussa tallennustilassa.'; + + @override + String get subscriptionUrlInvalid => + 'Anna kelvollinen HTTPS- tai webcal-URL ilman käyttäjätietoja tai fragmenttia.'; + + @override + String get subscriptionColor => 'Paikallinen väri'; + + @override + String get subscriptionColorHelp => + 'Käytä kuusinumeroista väriä, kuten #3584E4.'; + + @override + String get subscriptionColorInvalid => + 'Anna kuusinumeroinen heksadesimaaliväri.'; + + @override + String get subscriptionRefreshMode => 'Päivitystiheys'; + + @override + String get subscriptionAutomatic => 'Automaattinen'; + + @override + String get subscriptionHourly => 'Tunneittain'; + + @override + String get subscriptionSixHours => 'Kuuden tunnin välein'; + + @override + String get subscriptionDaily => 'Päivittäin'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Lähde: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Anna kelvollinen URL esikatsellaksesi sen suojattua alkuperää.'; + + @override + String get subscriptionReadOnly => 'Vain luku -tilaus'; + + @override + String get subscriptionNeverRefreshed => 'Ei vielä päivitetty'; + + @override + String subscriptionLastRefresh(String time) { + return 'Viimeisin onnistunut päivitys: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Seuraava päivitys: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Ajan tasalla'; + + @override + String subscriptionStatusIssue(String code) { + return 'Päivitysongelma: $code'; + } + + @override + String get refreshNow => 'Päivitä nyt'; + + @override + String get unsubscribe => 'Peruuta tilaus'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Perutaanko tilaus kalenterista ”$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'Tämä poistaa paikallisen tilauksen ja sen välimuistissa olevat tapahtumat. Julkaistua kalenteria ei muuteta.'; + + @override + String get addSubscriptionAction => 'Lisää tilaus'; + + @override + String subscriptionOperationFailed(String error) { + return 'Kalenteritilaus epäonnistui: $error'; + } + + @override + String get subscriptions => 'Tilaukset'; + + @override + String get calendarImport => 'Kalenterin tuonti'; + + @override + String get calendarImportDescription => + 'Valitse tiedosto, tarkista sen tapahtumat ja valitse sitten muokattava kalenteri, joka vastaanottaa ne.'; + + @override + String get importIcsFile => 'Tuo .ics-tiedosto'; + + @override + String get importIcsPreview => 'Tuo kalenteritapahtumat'; + + @override + String importEventsFound(int count) { + return 'Tuotavat tapahtumajoukot: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Virheelliset tapahtumat: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Jätetty tarkoituksella pois: $fields'; + } + + @override + String get noWritableCalendars => + 'Muokattavaa kohdekalenteria ei ole saatavilla.'; + + @override + String get importDestinationCalendar => 'Kohdekalenteri'; + + @override + String get importIcsConfirm => 'Tuo tapahtumat'; + + @override + String get importIcsComplete => 'Tuonti valmis'; + + @override + String importQueued(int count) { + return 'Tuotu tai jonossa: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Ohitetut kaksoiskappaleet: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Tukemattomat toistojoukot: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Kalenteritiedostoa ei voitu tuoda: $error'; + } + @override String get networkOffline => 'Ei verkkoyhteyttä'; @@ -1940,4 +2531,153 @@ class AppLocalizationsFi extends AppLocalizations { @override String get networkOfflineTryAgain => 'Verkkoyhteyttä ei ole. Yhdistä internetiin ja yritä uudelleen.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'kuukauden $days päivänä'; + } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(String day) { + return '$day.'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first ja $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first ja $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, { + 'jan': 'tammikuun', + 'feb': 'helmikuun', + 'mar': 'maaliskuun', + 'apr': 'huhtikuun', + 'may': 'toukokuun', + 'jun': 'kesäkuun', + 'jul': 'heinäkuun', + 'aug': 'elokuun', + 'sep': 'syyskuun', + 'oct': 'lokakuun', + 'nov': 'marraskuun', + 'dec': 'joulukuun', + 'other': '$month', + }); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first ja $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first ja $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency $month $day päivänä'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency $month $days päivänä'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency $months $day päivänä'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency $months $days päivänä'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'ensimmäisenä', + 'second': 'toisena', + 'third': 'kolmantena', + 'fourth': 'neljäntenä', + 'fifth': 'viidentenä', + 'secondToLast': 'toiseksi viimeisenä', + 'last': 'viimeisenä', + 'other': '', + }); + return '$frequency $month $_temp0 $days'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'ensimmäisenä', + 'second': 'toisena', + 'third': 'kolmantena', + 'fourth': 'neljäntenä', + 'fifth': 'viidentenä', + 'secondToLast': 'toiseksi viimeisenä', + 'last': 'viimeisenä', + 'other': '', + }); + return '$frequency $months $_temp0 $days'; + } } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 7bbb26ce..b21b3429 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -12,12 +12,110 @@ import 'app_localizations.dart'; class AppLocalizationsFr extends AppLocalizations { AppLocalizationsFr([String locale = 'fr']) : super(locale); + @override + String get windowsSupport => 'Assistance'; + + @override + String get windowsThirdPartyLicenses => 'Licences tierces'; + + @override + String get windowsSearch => 'Rechercher'; + + @override + String get windowsStartupDisabledByUser => + 'Désactivé par l’utilisateur dans les paramètres Windows.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Désactivé par une stratégie Windows.'; + + @override + String get windowsStartupUnavailable => + 'Disponible après l’installation de BusyMax à partir d’un package MSIX.'; + + @override + String get windowsReminderExitNotice => + 'Les rappels s’arrêtent lorsque BusyMax est complètement fermé. Laissez-le s’exécuter en arrière-plan pour les recevoir.'; + + @override + String get windowsProductVersionLabel => 'Version du produit'; + + @override + String get windowsPackageVersionLabel => 'Version du package Windows'; + + @override + String get windowsUnpackaged => 'Non empaqueté'; + + @override + String get windowsAgendaLoadMore => 'Charger plus d’éléments de l’agenda'; + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'le lundi', + 'TU': 'le mardi', + 'WE': 'le mercredi', + 'TH': 'le jeudi', + 'FR': 'le vendredi', + 'SA': 'le samedi', + 'SU': 'le dimanche', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'les jours $first et $second du mois'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency les jours $firstDay et $secondDay de $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency le $day de $firstMonth et $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency les jours $firstDay et $secondDay de $firstMonth et $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency les jours $days de $firstMonth et $secondMonth'; + } + @override String get appTitle => 'BusyMax'; @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Connectez les comptes Google, Microsoft, Calendrier Apple iCloud ou Nextcloud.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +139,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Ajoutez tous les comptes que vous souhaitez utiliser. BusyMax synchronise les calendriers, événements, listes de tâches et tâches pris en charge de chaque compte.'; @override String get onboardingPreferencesStepTitle => 'Choisir les paramètres système'; @@ -86,7 +184,7 @@ class AppLocalizationsFr extends AppLocalizations { String get exit => 'Quitter'; @override - String get options => 'Options'; + String get options => 'Choix'; @override String get hide => 'Masquer'; @@ -118,6 +216,44 @@ class AppLocalizationsFr extends AppLocalizations { @override String get calendars => 'Calendriers'; + @override + String get newCalendar => 'Nouveau calendrier'; + + @override + String get calendarColor => 'Couleur du calendrier'; + + @override + String calendarColorOption(int number) { + return 'Couleur $number'; + } + + @override + String get calendarManagementUnsupported => + 'Ce fournisseur ne prend pas en charge la gestion des calendriers dans BusyMax.'; + + @override + String get primaryCalendarCannotDelete => + 'Le calendrier principal ne peut pas être supprimé.'; + + @override + String calendarCreateFailed(String error) { + return 'Impossible de créer le calendrier : $error'; + } + + @override + String get calendarCreatedRefreshPending => + 'Le calendrier a été créé, mais BusyMax n’a pas pu actualiser le compte. Il apparaîtra après la prochaine synchronisation.'; + + @override + String calendarUpdateFailed(String error) { + return 'Impossible de mettre à jour le calendrier : $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Impossible de supprimer le calendrier : $error'; + } + @override String get newEvent => 'Nouvel événement'; @@ -141,7 +277,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String moreItems(int count) { - return '+$count de plus'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString de plus'; } @override @@ -181,6 +321,101 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trayOpenBusyMax => 'Ouvrir BusyMax'; + @override + String get trayShowBusyMax => 'Afficher BusyMax'; + + @override + String get trayNewEvent => 'Nouvel événement…'; + + @override + String get trayNewTask => 'Nouvelle tâche…'; + + @override + String get trayToday => 'Aujourd’hui'; + + @override + String get trayAllDay => 'Toute la journée'; + + @override + String get trayNow => 'Maintenant'; + + @override + String get trayCalendarEvent => 'Événement du calendrier'; + + @override + String get trayUntitledEvent => 'Événement sans titre'; + + @override + String get trayNothingElseToday => 'Rien d’autre aujourd’hui'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tâches arrivent à échéance aujourd’hui', + one: '1 tâche arrive à échéance aujourd’hui', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Ouvrir l’agenda du jour'; + + @override + String get traySyncNow => 'Synchroniser maintenant'; + + @override + String get traySyncing => 'Synchronisation…'; + + @override + String get trayNotConnected => 'Non connecté'; + + @override + String get trayNotYetSynced => 'Pas encore synchronisé'; + + @override + String get trayLastSyncedJustNow => 'Synchronisé à l’instant'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Synchronisé il y a $count minutes', + one: 'Synchronisé il y a 1 minute', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Synchronisé il y a $count heures', + one: 'Synchronisé il y a 1 heure', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Synchronisé il y a $count jours', + one: 'Synchronisé il y a 1 jour', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Paramètres'; + + @override + String get trayQuitBusyMax => 'Quitter BusyMax'; + @override String get agendaLoadMoreOverdue => 'Charger plus de tâches en retard'; @@ -200,7 +435,7 @@ class AppLocalizationsFr extends AppLocalizations { String get viewYear => 'Année'; @override - String get viewAgenda => 'Agenda'; + String get viewAgenda => 'Vue agenda'; @override String get scheduleSettings => 'Planning'; @@ -264,130 +499,131 @@ class AppLocalizationsFr extends AppLocalizations { String get noGuests => 'Aucun invité'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Obligatoire'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Facultatif'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Réunion'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Ajouter Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Ajouter une réunion Microsoft Teams'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Réunion en ligne ajoutée'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Demander des réponses'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'Demandez aux invités de répondre à l’invitation.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Masquer la liste des invités'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'Les invités ne peuvent pas voir qui d’autre a été invité.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => + 'Autoriser les nouvelles propositions d’horaire'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Les invités peuvent proposer une autre heure de réunion.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'Notifier les invités ?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'Cette réunion comporte des invités. Envoyer les invitations ou les mises à jour de l’événement lors de l’enregistrement ?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'Cette réunion comporte des invités. Envoyer une annulation lors de sa suppression ?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Envoyer les mises à jour'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Envoyer l’annulation'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'Ne pas envoyer'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'Enregistrer la réunion ?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft enverra les invitations ou les mises à jour de l’événement aux invités.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'Supprimer la réunion ?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft enverra une annulation aux invités.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Organisateur'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'Votre réponse'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Réponses des invités'; @override - String get respond => 'Respond'; + String get respond => 'Répondre'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Accepter'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Provisoire'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Refuser'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'Rejoindre la réunion'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Acceptée'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Provisoire'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Refusée'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'Réponse attendue'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Sans réponse'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Organisateur'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'Impossible d’envoyer votre réponse : $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'Impossible d’ouvrir le lien de la réunion.'; @override - String get description => 'Description'; + String get description => 'Détails'; @override String get availabilityShowAs => 'Disponibilité / Afficher comme'; @@ -509,7 +745,7 @@ class AppLocalizationsFr extends AppLocalizations { String get taskLists => 'Listes de tâches'; @override - String get navigation => 'Navigation'; + String get navigation => 'Déplacement'; @override String get mainMenu => 'Menu principal'; @@ -525,7 +761,7 @@ class AppLocalizationsFr extends AppLocalizations { 'Afficher cette liste de raccourcis'; @override - String get shortcutGroupNavigation => 'Navigation'; + String get shortcutGroupNavigation => 'Déplacement'; @override String get shortcutNextPeriod => 'Période suivante'; @@ -645,7 +881,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Ajoute uniquement la version de votre système d’exploitation Linux et la langue de l’application. Aucun journal, aucune donnée de compte, aucun nom de fichier ni autre diagnostic n’est inclus.'; + 'Ajoute uniquement le nom et la version de votre système d’exploitation et la langue de l’application. Aucun journal, aucune donnée de compte, aucun nom de fichier ni autre diagnostic n’est inclus.'; @override String get feedbackCategoryRequired => 'Sélectionnez une catégorie.'; @@ -667,7 +903,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get feedbackTimeoutError => - 'La demande a expiré. Vos commentaires n’ont pas été effacés ; réessayez.'; + 'La demande a expiré. Vos commentaires n’ont pas été effacés ; réessayez.'; @override String get feedbackRateLimitedError => @@ -679,11 +915,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String get feedbackServerError => - 'BusyStack ne peut pas accepter vos commentaires pour le moment. Vos commentaires n’ont pas été effacés ; réessayez.'; + 'BusyStack ne peut pas accepter vos commentaires pour le moment. Vos commentaires n’ont pas été effacés ; réessayez.'; @override String feedbackSuccess(String id) { - return 'Commentaires envoyés. Référence : $id'; + return 'Commentaires envoyés. Référence : $id'; } @override @@ -736,7 +972,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'Cette action supprime de cet appareil les tâches, calendriers, événements, rappels et modifications hors ligne en attente mis en cache. Les modifications non synchronisées seront perdues. Les copies des calendriers, événements, listes de tâches et tâches conservées chez le fournisseur ne sont pas supprimées.'; @override String get revokeGoogleAccess => @@ -762,17 +998,17 @@ class AppLocalizationsFr extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'Impossible de créer la liste de tâches : $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'Impossible de renommer la liste de tâches : $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'Impossible de supprimer la liste de tâches : $error'; } @override @@ -802,11 +1038,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'Cette liste de tâches est en lecture seule et ne peut pas être renommée.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'Cette liste de tâches ne peut pas être supprimée avec vos autorisations actuelles.'; @override String get builtInMicrosoftList => 'Intégrée'; @@ -817,17 +1053,17 @@ class AppLocalizationsFr extends AppLocalizations { @override String deleteListConfirmation(String title) { - return 'Supprimer « $title » de Google Tasks ?'; + return 'Supprimer « $title » de Google Tasks ?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return 'Supprimer « $title » et toutes ses tâches ?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'Ne plus partager « $title » avec ce compte ?'; } @override @@ -864,12 +1100,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String exportFailed(String error) { - return 'Échec de l’export : $error'; + return 'Échec de l’export : $error'; } @override String refreshFailed(String error) { - return 'Échec de l’actualisation : $error'; + return 'Échec de l’actualisation : $error'; } @override @@ -955,75 +1191,94 @@ class AppLocalizationsFr extends AppLocalizations { String get doneStatus => 'Terminée'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'État'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Aucun état'; @override - String get taskStatusNeedsAction => 'Nécessite une action'; + String get taskStatusNeedsAction => 'Action requise'; @override String get taskStatusInProcess => 'En cours'; @override - String get taskStatusCompleted => 'Terminé'; + String get taskStatusCompleted => 'Terminée'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Annulée'; @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString % terminée'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Date d’achèvement'; @override String get priority => 'Priorité'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Aucune priorité'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorité $priorityString · élevée'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorité $priorityString · moyenne'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorité $priorityString · faible'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'URL de la tâche'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'Saisissez une URL absolue, avec son schéma.'; @override - String get classification => 'Classification'; + String get classification => 'Catégorisation'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => + 'Lorsqu’elle est partagée, afficher la tâche complète'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => + 'Lorsqu’elle est partagée, afficher uniquement l’état occupé'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => + 'Lorsqu’elle est partagée, masquer cette tâche'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Épingler la tâche'; @override - String get notes => 'Notes'; + String get notes => 'Remarques'; @override String get dueDate => 'Date d’échéance'; @@ -1059,82 +1314,88 @@ class AppLocalizationsFr extends AppLocalizations { String get addReminder => 'Ajouter un rappel'; @override - String get reminders => 'Reminders'; + String get reminders => 'Rappels'; @override String get noReminders => 'Aucun rappel'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Modifier le rappel'; @override String get beforeTaskStarts => 'Avant le début de la tâche'; @override - String get beforeTaskDue => 'Avant l\'échéance de la tâche'; + String get beforeTaskDue => 'Avant l’échéance de la tâche'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'Après le début de la tâche'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'Après l’échéance de la tâche'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => + 'Par rapport à la date de début de la tâche'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => + 'Par rapport à la date d’échéance de la tâche'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Heure de la journée'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'À une date et une heure'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Quantité'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Unité'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Secondes'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'min'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Heures'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Jours'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Semaines'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'Au début de la tâche'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'À l’échéance de la tâche'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'Ce type de rappel est conservé, mais son heure ne peut pas être modifiée.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'Conserver les rappels associés ?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Cette date comporte $countString rappels associés. Les conserver à leur date et heure actuelles ?'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => 'Supprimer les rappels'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => 'Conserver les rappels'; @override String get addGuest => 'Ajouter un invité'; @@ -1170,124 +1431,183 @@ class AppLocalizationsFr extends AppLocalizations { String get repeatYearly => 'Annuel'; @override - String get repeatEvery => 'Répéter chaque'; + String get repeatEvery => 'Intervalle'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'Répéter le'; @override - String get repeatEnd => 'Arrêter la répétition'; + String get repeatEnd => 'Terminer la répétition'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'Jamais'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'À une date'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'Après un certain nombre de répétitions'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'Nombre d’occurrences'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'Jours du mois'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'Mois'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'Position du jour de la semaine'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'Jours précis'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'Premier'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'Deuxième'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'Troisième'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'Quatrième'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'Cinquième'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'Avant-dernier'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'Dernier'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'Jour'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'Jour de semaine'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'Jour du week-end'; + + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'lundi', + 'TU': 'mardi', + 'WE': 'mercredi', + 'TH': 'jeudi', + 'FR': 'vendredi', + 'SA': 'samedi', + 'SU': 'dimanche', + 'day': 'jour', + 'weekday': 'jour de semaine', + 'weekend': 'jour du week-end', + 'other': '$day', + }); + return '$_temp0'; + } @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Tous les $countString jours'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Toutes les $countString semaines'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Tous les $countString mois'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Toutes les $countString années'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return '$days'; } @override String repeatOnMonthDaysSummary(String days) { - return 'le $days'; + return 'le $days du mois'; } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'le premier $days', + 'second': 'le deuxième $days', + 'third': 'le troisième $days', + 'fourth': 'le quatrième $days', + 'fifth': 'le cinquième $days', + 'secondToLast': 'l’avant-dernier $days', + 'last': 'le dernier $days', + 'other': 'les $days', + }); + return '$_temp0'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return 'en $months'; } @override String repeatTimesSummary(int count) { - return '$count fois'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString fois', + one: '$countString fois', + ); + return '$_temp0'; } @override String repeatUntilSummary(String date) { - return 'jusqu\'au $date'; + return 'jusqu’au $date'; } @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'Cette règle de récurrence utilise des options que cet éditeur ne modifie pas.'; @override - String get importance => 'Importance'; + String recurrenceUnsupportedByProvider(String provider) { + return 'Cette récurrence ne peut pas être utilisée avec $provider.'; + } + + @override + String get importance => 'Niveau d’importance'; @override String get importanceLow => 'Faible'; @@ -1317,7 +1637,7 @@ class AppLocalizationsFr extends AppLocalizations { String get organizationSection => 'Organisation'; @override - String get actionsSection => 'Actions'; + String get actionsSection => 'Opérations'; @override String get advancedSection => 'Avancé'; @@ -1339,21 +1659,21 @@ class AppLocalizationsFr extends AppLocalizations { String get subtasks => 'Sous-tâches'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'Dupliquer la tâche'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Tâche dupliquée.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'Impossible de dupliquer la tâche : $error'; } @override String get hideSubtasks => 'Masquer les sous-tâches'; @override - String get hideClosedSubtasks => 'Masquer les sous-tâches fermées'; + String get hideClosedSubtasks => 'Masquer les sous-tâches terminées'; @override String get moveToTop => 'Déplacer tout en haut'; @@ -1366,7 +1686,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String deleteTaskConfirmation(String title) { - return 'Supprimer « $title » ?'; + return 'Supprimer « $title » ?'; } @override @@ -1385,7 +1705,7 @@ class AppLocalizationsFr extends AppLocalizations { String get parent => 'Tâche parente'; @override - String get position => 'Position'; + String get position => 'Ordre'; @override String get webLink => 'Lien web'; @@ -1409,7 +1729,11 @@ class AppLocalizationsFr extends AppLocalizations { String get sync => 'Synchronisation'; @override - String get manualFullSync => 'Synchronisation complète manuelle'; + String get forceFullResync => 'Forcer une resynchronisation complète'; + + @override + String get forceFullResyncDescription => + 'Recharge entièrement les données de chaque compte connecté. N’utilisez cette option que pour résoudre des problèmes de synchronisation.'; @override String get runInBackgroundWhenClosed => @@ -1442,7 +1766,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String syncFailed(String error) { - return 'Échec de la synchronisation : $error'; + return 'Échec de la synchronisation : $error'; } @override @@ -1457,6 +1781,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get eventReminders => 'Rappels d’événements'; + @override + String get onState => 'Activé'; + @override String get taskReminders => 'Rappels de tâches'; @@ -1467,7 +1794,7 @@ class AppLocalizationsFr extends AppLocalizations { String get notificationDetailPrivate => 'Privé'; @override - String get notificationDetailNormal => 'Normal'; + String get notificationDetailNormal => 'Standard'; @override String get quietHours => 'Période de silence'; @@ -1483,7 +1810,19 @@ class AppLocalizationsFr extends AppLocalizations { String get quietHoursEnd => 'Fin de la période de silence'; @override - String get notifications => 'Notifications'; + String get notifications => 'Alertes'; + + @override + String get windowsNotificationsUnavailable => + 'Les notifications Windows sont indisponibles'; + + @override + String get windowsNotificationsUnpackaged => + 'Cette exécution de développement non empaquetée ne peut pas utiliser les notifications Windows. Installez le MSIX signé pour les tests afin de vérifier les rappels.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax n’a pas pu initialiser les notifications Windows. Les rappels ne s’afficheront pas tant que ce problème d’installation ne sera pas résolu.'; @override String get appearance => 'Apparence'; @@ -1494,6 +1833,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get themeSystem => 'Système'; + @override + String get settingsSystem => 'Système'; + @override String get themeLight => 'Clair'; @@ -1523,7 +1865,7 @@ class AppLocalizationsFr extends AppLocalizations { String get developerDiagnostics => 'Diagnostics développeur'; @override - String get diagnostics => 'Diagnostics'; + String get diagnostics => 'Informations de diagnostic'; @override String get apiInspectorDisabled => 'Afficher l’inspecteur API'; @@ -1533,7 +1875,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String discoveryRevision(String revision) { - return 'Révision Discovery : $revision'; + return 'Révision Discovery : $revision'; } @override @@ -1572,7 +1914,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'tentatives=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'tentatives=$countString'; } @override @@ -1585,7 +1931,7 @@ class AppLocalizationsFr extends AppLocalizations { String get discardChangesAction => 'Abandonner'; @override - String get discardChanges => 'Abandonner les modifications ?'; + String get discardChanges => 'Abandonner les modifications ?'; @override String get discardChangesConfirmation => @@ -1595,7 +1941,7 @@ class AppLocalizationsFr extends AppLocalizations { String get retryCompleted => 'Nouvelle tentative terminée.'; @override - String get discardPendingOperation => 'Abandonner l’opération en attente ?'; + String get discardPendingOperation => 'Abandonner l’opération en attente ?'; @override String get discardPendingOperationConfirmation => @@ -1626,10 +1972,14 @@ class AppLocalizationsFr extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tâches sont dues aujourd’hui.', + other: '$countString tâches sont dues aujourd’hui.', one: 'Une tâche est due aujourd’hui.', ); return '$_temp0'; @@ -1655,7 +2005,7 @@ class AppLocalizationsFr extends AppLocalizations { String get notificationSnoozeAction => 'Rappeler dans 10 minutes'; @override - String get notificationDismissAction => 'Ignorer'; + String get notificationDismissAction => 'Fermer'; @override String get notificationDetailsHidden => @@ -1712,14 +2062,14 @@ class AppLocalizationsFr extends AppLocalizations { String get noLocationsFound => 'Aucun lieu trouvé'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'Ce champ est obligatoire.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Connectez les calendriers et les tâches à l’un de ces fournisseurs.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Calendrier Apple iCloud'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1728,198 +2078,225 @@ class AppLocalizationsFr extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Tâches Nextcloud'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => + 'Ajouter un compte Calendrier Apple iCloud'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Ajouter un compte Nextcloud'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Connexion à Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'En attente de l’autorisation Nextcloud…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Connecter le Calendrier Apple iCloud'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'E-mail du compte Apple'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'Mot de passe spécifique à l’app'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Créez un mot de passe spécifique à l’app après avoir activé l’authentification à deux facteurs de votre compte Apple.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'La réinitialisation du mot de passe de votre compte Apple révoque les mots de passe spécifiques aux apps.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Connecter Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Serveur Nextcloud ou adresse CalDAV'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Saisissez l’URL de votre serveur Nextcloud ou collez l’adresse CalDAV principale copiée depuis Nextcloud.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax va ouvrir votre navigateur. Autorisez-y l’accès, puis revenez dans BusyMax.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Connecter'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Annuler la connexion'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'Le compte a été supprimé localement, mais le mot de passe d’app Nextcloud n’a pas pu être révoqué.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'Les données des calendriers et des tâches sont mises en cache localement pour une utilisation hors connexion.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Reconnectez ce compte pour reprendre la synchronisation.'; @override String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + 'Ce compte est temporairement indisponible.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'Les autorisations du serveur ont changé. Les modifications en attente sont suspendues.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Ce serveur ou profil de fournisseur n’est pas pris en charge.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Calendriers et listes de tâches'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'Événements du calendrier'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Tâches'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Lecture seule'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'En attente localement'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Bloqué par un conflit'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'Bloqué jusqu’à la reconnexion'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'Échec de l’opération'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Conserver la version du serveur'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => + 'Examiner et réappliquer la modification locale'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Dupliquer comme nouvel élément'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'État de la connexion'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Connecté'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'Connexion…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Déconnecté'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Dernière synchronisation réussie : $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Pas encore synchronisé'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => + 'Actualiser les calendriers et les listes de tâches'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Serveur : $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Calendrier d’événements'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Liste de tâches'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'Événements et tâches'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Modifiable'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Partagé'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Dernière synchronisation : $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Problème de synchronisation : $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Conflits de synchronisation'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Modification du serveur : $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Modification locale : $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'Impossible de résoudre le conflit.'; + + @override + String get recurringEventScope => 'Portée de l’événement récurrent'; + + @override + String get entireSeries => 'Série entière'; + + @override + String get singleOccurrence => 'Cet événement'; + + @override + String get thisAndFollowingEvents => 'Cet événement et les suivants'; + + @override + String get thisAndFutureUnavailable => + 'Non pris en charge par ce fournisseur.'; + + @override + String get thisAndFutureMoveUnavailable => + 'Cet événement et les suivants ne peuvent pas être déplacés de manière fiable. Choisissez cet événement ou toute la série.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get entireSeriesMoveUnavailable => + 'La règle de récurrence n’est pas disponible localement. Déplacez uniquement cet événement.'; @override - String get entireSeries => 'Entire series'; + String get copyEventAndDeleteOriginal => + 'Copier l’événement et supprimer l’original ?'; @override - String get singleOccurrence => 'This occurrence'; + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax ne peut pas déplacer directement cet événement de $source vers $destination. La copie sera créée en premier et l’original ne sera supprimé qu’après la réussite de la copie. Les identifiants changeront ; les statuts de réponse des participants pourront être réinitialisés et des invitations ou annulations envoyées ; les liens de conférence, pièces jointes, rappels, champs propres au fournisseur et exceptions de récurrence pourront ne pas être conservés.'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => 'Copier et supprimer'; @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Choisissez si cette modification s’applique à toute la série, à cet événement uniquement ou à cet événement et aux suivants.'; @override String get taskDueBeforeStart => 'L’échéance ne peut pas précéder le début.'; @@ -1930,7 +2307,202 @@ class AppLocalizationsFr extends AppLocalizations { @override String deleteCalendarConfirmation(String title) { - return 'Supprimer « $title » ?'; + return 'Supprimer « $title » ?'; + } + + @override + String get setCustomCalendarName => 'Définir un nom personnalisé'; + + @override + String get setAction => 'Définir'; + + @override + String get removeFromMyCalendars => 'Retirer de mes agendas'; + + @override + String get removeAction => 'Retirer'; + + @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.'; + } + + @override + String get calendarCannotRemove => + 'Ce calendrier ne peut pas être supprimé ou retiré de ce compte.'; + + @override + String get calendarPendingChangesPreventRemoval => + 'Attendez que les modifications en attente de ce calendrier aient fini de se synchroniser avant de le supprimer ou de le retirer.'; + + @override + String get calendarSubscriptions => 'Abonnements aux calendriers'; + + @override + String get calendarSubscriptionsDescription => + 'Ajoutez des calendriers en lecture seule qui se mettent à jour depuis une URL WebCal sécurisée.'; + + @override + String get addCalendarSubscription => 'Ajouter un abonnement de calendrier'; + + @override + String get subscriptionName => 'Nom local'; + + @override + String get subscriptionUrl => 'URL de l’abonnement'; + + @override + String get subscriptionUrlHelp => + 'Saisissez une URL HTTPS ou webcal. BusyMax conserve l’URL complète dans un stockage sécurisé.'; + + @override + String get subscriptionUrlInvalid => + 'Saisissez une URL HTTPS ou webcal valide, sans informations utilisateur ni fragment.'; + + @override + String get subscriptionColor => 'Couleur locale'; + + @override + String get subscriptionColorHelp => + 'Utilisez une couleur à six chiffres, comme #3584E4.'; + + @override + String get subscriptionColorInvalid => + 'Saisissez une couleur hexadécimale à six chiffres.'; + + @override + String get subscriptionRefreshMode => 'Fréquence d’actualisation'; + + @override + String get subscriptionAutomatic => 'Automatique'; + + @override + String get subscriptionHourly => 'Toutes les heures'; + + @override + String get subscriptionSixHours => 'Toutes les six heures'; + + @override + String get subscriptionDaily => 'Tous les jours'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source : $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Saisissez une URL valide pour prévisualiser son origine sécurisée.'; + + @override + String get subscriptionReadOnly => 'Abonnement en lecture seule'; + + @override + String get subscriptionNeverRefreshed => 'Pas encore actualisé'; + + @override + String subscriptionLastRefresh(String time) { + return 'Dernière actualisation réussie : $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Prochaine actualisation : $time'; + } + + @override + String get subscriptionStatusHealthy => 'À jour'; + + @override + String subscriptionStatusIssue(String code) { + return 'Problème d’actualisation : $code'; + } + + @override + String get refreshNow => 'Actualiser maintenant'; + + @override + String get unsubscribe => 'Se désabonner'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Se désabonner de « $name » ?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'Cette action supprime l’abonnement local et ses événements mis en cache. Le calendrier publié n’est pas modifié.'; + + @override + String get addSubscriptionAction => 'Ajouter un abonnement'; + + @override + String subscriptionOperationFailed(String error) { + return 'Échec de l’abonnement au calendrier : $error'; + } + + @override + String get subscriptions => 'Abonnements'; + + @override + String get calendarImport => 'Importation de calendrier'; + + @override + String get calendarImportDescription => + 'Sélectionnez un fichier, vérifiez ses événements, puis choisissez le calendrier modifiable qui doit les recevoir.'; + + @override + String get importIcsFile => 'Importer un fichier .ics'; + + @override + String get importIcsPreview => 'Importer des événements du calendrier'; + + @override + String importEventsFound(int count) { + return 'Ensembles d’événements importables : $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Événements non valides : $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Exclus intentionnellement : $fields'; + } + + @override + String get noWritableCalendars => + 'Aucun calendrier de destination modifiable n’est disponible.'; + + @override + String get importDestinationCalendar => 'Calendrier de destination'; + + @override + String get importIcsConfirm => 'Importer les événements'; + + @override + String get importIcsComplete => 'Importation terminée'; + + @override + String importQueued(int count) { + return 'Importés ou mis en file d’attente : $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Doublons ignorés : $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Ensembles de récurrence non pris en charge : $count'; + } + + @override + String importIcsFailed(String error) { + return 'Impossible d’importer le fichier de calendrier : $error'; } @override @@ -1943,4 +2515,139 @@ class AppLocalizationsFr extends AppLocalizations { @override String get networkOfflineTryAgain => 'Vous êtes hors ligne. Connectez-vous à Internet et réessayez.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'les $days du mois'; + } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(String day) { + return '$day'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first et $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first et $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first et $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first et $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency le $day $month'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency les jours $days de $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency le $day de $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency les jours $days de $months'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'le premier $days de $month', + 'second': 'le deuxième $days de $month', + 'third': 'le troisième $days de $month', + 'fourth': 'le quatrième $days de $month', + 'fifth': 'le cinquième $days de $month', + 'secondToLast': 'l’avant-dernier $days de $month', + 'last': 'le dernier $days de $month', + 'other': 'les $days de $month', + }); + return '$frequency $_temp0'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'le premier $days de $months', + 'second': 'le deuxième $days de $months', + 'third': 'le troisième $days de $months', + 'fourth': 'le quatrième $days de $months', + 'fifth': 'le cinquième $days de $months', + 'secondToLast': 'l’avant-dernier $days de $months', + 'last': 'le dernier $days de $months', + 'other': 'les $days de $months', + }); + return '$frequency $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 604ac231..25c1bb25 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -12,12 +12,110 @@ import 'app_localizations.dart'; class AppLocalizationsHi extends AppLocalizations { AppLocalizationsHi([String locale = 'hi']) : super(locale); + @override + String get windowsSupport => 'सहायता'; + + @override + String get windowsThirdPartyLicenses => 'तृतीय-पक्ष लाइसेंस'; + + @override + String get windowsSearch => 'खोजें'; + + @override + String get windowsStartupDisabledByUser => + 'उपयोगकर्ता ने Windows सेटिंग में इसे बंद किया है।'; + + @override + String get windowsStartupDisabledByPolicy => + 'Windows नीति द्वारा बंद किया गया है।'; + + @override + String get windowsStartupUnavailable => + 'BusyMax को MSIX पैकेज से इंस्टॉल करने के बाद उपलब्ध होगा।'; + + @override + String get windowsReminderExitNotice => + 'BusyMax को पूरी तरह बंद करने पर रिमाइंडर रुक जाते हैं। उन्हें पाने के लिए इसे पृष्ठभूमि में चलने दें।'; + + @override + String get windowsProductVersionLabel => 'उत्पाद संस्करण'; + + @override + String get windowsPackageVersionLabel => 'Windows पैकेज संस्करण'; + + @override + String get windowsUnpackaged => 'पैकेज रहित'; + + @override + String get windowsAgendaLoadMore => 'एजेंडा के और आइटम लोड करें'; + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'सोमवार', + 'TU': 'मंगलवार', + 'WE': 'बुधवार', + 'TH': 'गुरुवार', + 'FR': 'शुक्रवार', + 'SA': 'शनिवार', + 'SU': 'रविवार', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'की $first और $second तारीखों को'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency $month की $firstDay और $secondDay तारीखों को'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency $firstMonth और $secondMonth की $day तारीख को'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency $firstMonth और $secondMonth की $firstDay और $secondDay तारीखों को'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency $firstMonth और $secondMonth की $days तारीखों को'; + } + @override String get appTitle => 'BusyMax'; @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Google, Microsoft, Apple iCloud Calendar या Nextcloud खाते कनेक्ट करें।'; @override String get googlePermissionsConsentNotice => @@ -41,7 +139,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'वे सभी खाते जोड़ें जिनका आप उपयोग करना चाहते हैं। BusyMax हर खाते से समर्थित कैलेंडर, इवेंट, कार्य सूचियाँ और कार्य सिंक करता है।'; @override String get onboardingPreferencesStepTitle => 'सिस्टम सेटिंग्स चुनें'; @@ -108,7 +206,7 @@ class AppLocalizationsHi extends AppLocalizations { String get all => 'सभी'; @override - String get calendarEvents => 'ईवेंट'; + String get calendarEvents => 'इवेंट'; @override String get calendarTasks => 'कार्य'; @@ -120,7 +218,45 @@ class AppLocalizationsHi extends AppLocalizations { String get calendars => 'कैलेंडर'; @override - String get newEvent => 'नया ईवेंट'; + String get newCalendar => 'नया कैलेंडर'; + + @override + String get calendarColor => 'कैलेंडर का रंग'; + + @override + String calendarColorOption(int number) { + return 'रंग $number'; + } + + @override + String get calendarManagementUnsupported => + 'यह प्रदाता BusyMax में कैलेंडर प्रबंधन का समर्थन नहीं करता है।'; + + @override + String get primaryCalendarCannotDelete => + 'प्राथमिक कैलेंडर को हटाया नहीं जा सकता।'; + + @override + String calendarCreateFailed(String error) { + return 'कैलेंडर नहीं बनाया जा सका: $error'; + } + + @override + String get calendarCreatedRefreshPending => + 'कैलेंडर बन गया, लेकिन BusyMax खाते को रीफ़्रेश नहीं कर सका। यह अगली सिंक के बाद दिखाई देगा।'; + + @override + String calendarUpdateFailed(String error) { + return 'कैलेंडर अपडेट नहीं किया जा सका: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'कैलेंडर हटाया नहीं जा सका: $error'; + } + + @override + String get newEvent => 'नया इवेंट'; @override String get refreshCalendar => 'कैलेंडर रीफ़्रेश करें'; @@ -142,11 +278,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String moreItems(int count) { - return '+$count और'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString और'; } @override - String get noEventsOrTasks => 'कोई ईवेंट या कार्य नहीं'; + String get noEventsOrTasks => 'कोई इवेंट या कार्य नहीं'; @override String get scheduleLoading => 'शेड्यूल लोड हो रहा है...'; @@ -170,7 +310,7 @@ class AppLocalizationsHi extends AppLocalizations { 'कैलेंडर और कार्य सिंक करने के लिए साइन इन करें।'; @override - String get scheduleNoSearchResults => 'कोई मिलता-जुलता ईवेंट या कार्य नहीं'; + String get scheduleNoSearchResults => 'कोई मिलता-जुलता इवेंट या कार्य नहीं'; @override String get scheduleNoSearchResultsDescription => @@ -182,6 +322,101 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trayOpenBusyMax => 'BusyMax खोलें'; + @override + String get trayShowBusyMax => 'BusyMax दिखाएँ'; + + @override + String get trayNewEvent => 'नया इवेंट…'; + + @override + String get trayNewTask => 'नया कार्य…'; + + @override + String get trayToday => 'आज'; + + @override + String get trayAllDay => 'पूरा दिन'; + + @override + String get trayNow => 'अभी'; + + @override + String get trayCalendarEvent => 'कैलेंडर इवेंट'; + + @override + String get trayUntitledEvent => 'बिना शीर्षक का इवेंट'; + + @override + String get trayNothingElseToday => 'आज और कुछ नहीं'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'आज $count कार्यों की नियत तारीख है', + one: 'आज 1 कार्य की नियत तारीख है', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'आज का एजेंडा खोलें'; + + @override + String get traySyncNow => 'अभी सिंक करें'; + + @override + String get traySyncing => 'सिंक हो रहा है…'; + + @override + String get trayNotConnected => 'कनेक्ट नहीं है'; + + @override + String get trayNotYetSynced => 'अभी तक सिंक नहीं हुआ'; + + @override + String get trayLastSyncedJustNow => 'अभी-अभी सिंक हुआ'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count मिनट पहले सिंक हुआ', + one: '1 मिनट पहले सिंक हुआ', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count घंटे पहले सिंक हुआ', + one: '1 घंटे पहले सिंक हुआ', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count दिन पहले सिंक हुआ', + one: '1 दिन पहले सिंक हुआ', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'सेटिंग्स'; + + @override + String get trayQuitBusyMax => 'BusyMax बंद करें'; + @override String get agendaLoadMoreOverdue => 'समय-सीमा पार कर चुके अतिरिक्त कार्य लोड करें'; @@ -230,16 +465,16 @@ class AppLocalizationsHi extends AppLocalizations { String get createChoiceTitle => 'बनाएँ'; @override - String get createEventAtTime => 'ईवेंट'; + String get createEventAtTime => 'इवेंट'; @override String get createTaskAtDate => 'कार्य'; @override - String get editEvent => 'ईवेंट संपादित करें'; + String get editEvent => 'इवेंट संपादित करें'; @override - String get eventTitle => 'ईवेंट का शीर्षक'; + String get eventTitle => 'इवेंट का शीर्षक'; @override String get location => 'स्थान'; @@ -266,127 +501,127 @@ class AppLocalizationsHi extends AppLocalizations { String get noGuests => 'कोई अतिथि नहीं'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'ज़रूरी'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'वैकल्पिक'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'मीटिंग'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Google Meet जोड़ें'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Microsoft Teams मीटिंग जोड़ें'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'ऑनलाइन मीटिंग जोड़ी गई'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'जवाब माँगें'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'अतिथियों से आमंत्रण का जवाब देने को कहें।'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'अतिथि सूची छिपाएँ'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'अतिथि यह नहीं देख सकते कि और किसे आमंत्रित किया गया है।'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'नए समय प्रस्तावों की अनुमति दें'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'अतिथि मीटिंग का अलग समय सुझा सकते हैं।'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'अतिथियों को सूचित करें?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'इस मीटिंग में अतिथि हैं। सहेजते समय आमंत्रण या इवेंट अपडेट भेजें?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'इस मीटिंग में अतिथि हैं। हटाते समय रद्दीकरण भेजें?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'अपडेट भेजें'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'रद्दीकरण भेजें'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'न भेजें'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'मीटिंग सहेजें?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft अतिथियों को आमंत्रण या इवेंट अपडेट भेजेगा।'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'मीटिंग हटाएँ?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft अतिथियों को रद्दीकरण भेजेगा।'; @override - String get organizer => 'Organizer'; + String get organizer => 'आयोजक'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'आपका जवाब'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'अतिथियों के जवाब'; @override - String get respond => 'Respond'; + String get respond => 'जवाब दें'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'स्वीकार करें'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'अनिश्चित'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'अस्वीकार करें'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'मीटिंग में शामिल हों'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'स्वीकृत'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'अनिश्चित'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'अस्वीकृत'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'जवाब की प्रतीक्षा है'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'जवाब नहीं दिया'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'आयोजक'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'आपका जवाब भेजा नहीं जा सका: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'मीटिंग लिंक खोला नहीं जा सका।'; @override String get description => 'विवरण'; @@ -432,10 +667,14 @@ class AppLocalizationsHi extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes मिनट पहले', + other: '$minutesString मिनट पहले', one: '1 मिनट पहले', ); return '$_temp0'; @@ -446,10 +685,14 @@ class AppLocalizationsHi extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours घंटे पहले', + other: '$hoursString घंटे पहले', one: '1 घंटा पहले', ); return '$_temp0'; @@ -457,10 +700,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days दिन पहले', + other: '$daysString दिन पहले', one: '1 दिन पहले', ); return '$_temp0'; @@ -567,10 +815,10 @@ class AppLocalizationsHi extends AppLocalizations { String get shortcutGroupCreateAndEdit => 'बनाएँ और संपादित करें'; @override - String get shortcutSaveItem => 'ईवेंट या कार्य सहेजें'; + String get shortcutSaveItem => 'इवेंट या कार्य सहेजें'; @override - String get shortcutDeleteItem => 'ईवेंट या कार्य मिटाएँ'; + String get shortcutDeleteItem => 'इवेंट या कार्य मिटाएँ'; @override String get shortcutGroupTaskEditing => 'कार्य संपादन'; @@ -645,7 +893,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'केवल आपके Linux ऑपरेटिंग सिस्टम का संस्करण और ऐप की भाषा व क्षेत्रीय सेटिंग जोड़ी जाती है। लॉग, खाता डेटा, फ़ाइल नाम या अन्य निदान जानकारी शामिल नहीं की जाती।'; + 'केवल आपके ऑपरेटिंग सिस्टम का नाम और संस्करण तथा ऐप की भाषा व क्षेत्रीय सेटिंग जोड़ी जाती है। लॉग, खाता डेटा, फ़ाइल नाम या अन्य निदान जानकारी शामिल नहीं की जाती।'; @override String get feedbackCategoryRequired => 'श्रेणी चुनें।'; @@ -731,12 +979,12 @@ class AppLocalizationsHi extends AppLocalizations { @override String removeAccountTitle(String account) { - return 'BusyMax से $account हटाएँ?'; + return '$account को BusyMax से हटाएँ?'; } @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'इससे इस डिवाइस से कैश किए गए कार्य, कैलेंडर, इवेंट, रिमाइंडर और लंबित ऑफ़लाइन बदलाव हट जाएँगे। सिंक न किए गए बदलाव खो जाएँगे। प्रदाता की कैलेंडर, इवेंट, कार्य सूची और कार्य प्रतियाँ हटाई नहीं जाएँगी।'; @override String get revokeGoogleAccess => @@ -762,17 +1010,17 @@ class AppLocalizationsHi extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'कार्य सूची बनाई नहीं जा सकी: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'कार्य सूची का नाम बदला नहीं जा सका: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'कार्य सूची हटाई नहीं जा सकी: $error'; } @override @@ -798,15 +1046,15 @@ class AppLocalizationsHi extends AppLocalizations { String get deleteList => 'सूची मिटाएँ'; @override - String get unshare => 'Unshare'; + String get unshare => 'साझा करना बंद करें'; @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'यह कार्य सूची केवल-पठन है और इसका नाम नहीं बदला जा सकता।'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'आपकी मौजूदा अनुमतियों से यह कार्य सूची हटाई नहीं जा सकती।'; @override String get builtInMicrosoftList => 'अंतर्निहित'; @@ -817,21 +1065,21 @@ class AppLocalizationsHi extends AppLocalizations { @override String deleteListConfirmation(String title) { - return 'Google Tasks से “$title” मिटाएँ?'; + return 'Google Tasks से “$title” हटाएँ?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return '“$title” और इसके सभी कार्य हटाएँ?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'इस खाते से “$title” साझा करना बंद करें?'; } @override - String get deleteEvent => 'ईवेंट मिटाएँ'; + String get deleteEvent => 'इवेंट मिटाएँ'; @override String get title => 'शीर्षक'; @@ -955,72 +1203,88 @@ class AppLocalizationsHi extends AppLocalizations { String get doneStatus => 'पूर्ण'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'स्थिति'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'कोई स्थिति नहीं'; @override - String get taskStatusNeedsAction => 'Needs action'; + String get taskStatusNeedsAction => 'कार्रवाई आवश्यक'; @override - String get taskStatusInProcess => 'In process'; + String get taskStatusInProcess => 'प्रगति में'; @override - String get taskStatusCompleted => 'Completed'; + String get taskStatusCompleted => 'पूर्ण'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'रद्द'; @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% पूर्ण'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'पूर्ण होने की तारीख'; @override - String get priority => 'Priority'; + String get priority => 'प्राथमिकता'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'कोई प्राथमिकता नहीं'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'प्राथमिकता $priorityString · उच्च'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'प्राथमिकता $priorityString · मध्यम'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'प्राथमिकता $priorityString · निम्न'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'कार्य URL'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'स्कीम सहित पूर्ण URL दर्ज करें।'; @override - String get classification => 'Classification'; + String get classification => 'वर्गीकरण'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => 'साझा होने पर पूरा कार्य दिखाएँ'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => 'साझा होने पर केवल व्यस्तता दिखाएँ'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'साझा होने पर यह कार्य छिपाएँ'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'कार्य पिन करें'; @override String get notes => 'नोट्स'; @@ -1059,82 +1323,86 @@ class AppLocalizationsHi extends AppLocalizations { String get addReminder => 'रिमाइंडर जोड़ें'; @override - String get reminders => 'Reminders'; + String get reminders => 'रिमाइंडर'; @override - String get noReminders => 'No reminders'; + String get noReminders => 'कोई रिमाइंडर नहीं'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'रिमाइंडर संपादित करें'; @override - String get beforeTaskStarts => 'Before the task starts'; + String get beforeTaskStarts => 'कार्य शुरू होने से पहले'; @override - String get beforeTaskDue => 'Before the task is due'; + String get beforeTaskDue => 'कार्य की नियत तारीख से पहले'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'कार्य शुरू होने के बाद'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'कार्य की नियत तारीख के बाद'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'कार्य शुरू होने की तारीख के सापेक्ष'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'कार्य की नियत तारीख के सापेक्ष'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'दिन का समय'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'तारीख और समय पर'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'मात्रा'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'इकाई'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'सेकंड'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'मिनट'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'घंटे'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'दिन'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'सप्ताह'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'कार्य शुरू होने पर'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'कार्य की नियत तारीख पर'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'यह रिमाइंडर प्रकार सुरक्षित है, लेकिन इसका समय संपादित नहीं किया जा सकता।'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'संबंधित रिमाइंडर रखें?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'इस तारीख पर $countString संबंधित रिमाइंडर हैं। इन्हें उनकी मौजूदा तारीख और समय पर रखें?'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => 'रिमाइंडर हटाएँ'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => 'रिमाइंडर रखें'; @override String get addGuest => 'अतिथि जोड़ें'; @@ -1170,121 +1438,180 @@ class AppLocalizationsHi extends AppLocalizations { String get repeatYearly => 'हर वर्ष'; @override - String get repeatEvery => 'Repeat every'; + String get repeatEvery => 'दोहराव अंतराल'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'दोहराएँ'; @override - String get repeatEnd => 'End repeat'; + String get repeatEnd => 'दोहराव समाप्त करें'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'कभी नहीं'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'तारीख तक'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'कई बार दोहराने के बाद'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'दोहराव की संख्या'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'महीने के दिन'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'महीने'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'सप्ताह के दिन की स्थिति'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'विशिष्ट दिन'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'पहला'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'दूसरा'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'तीसरा'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'चौथा'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'पाँचवाँ'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'अंतिम से दूसरा'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'अंतिम'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'दिन'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'सप्ताह का दिन'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'सप्ताहांत का दिन'; + + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'सोमवार', + 'TU': 'मंगलवार', + 'WE': 'बुधवार', + 'TH': 'गुरुवार', + 'FR': 'शुक्रवार', + 'SA': 'शनिवार', + 'SU': 'रविवार', + 'day': 'दिन', + 'weekday': 'सप्ताह का दिन', + 'weekend': 'सप्ताहांत का दिन', + 'other': '$day', + }); + return '$_temp0'; + } @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'हर $countString दिन'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'हर $countString सप्ताह'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'हर $countString महीने'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'हर $countString साल'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return '$days को'; } @override String repeatOnMonthDaysSummary(String days) { - return 'on day $days'; + return 'की $days तारीख को'; } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'के पहले $days को', + 'second': 'के दूसरे $days को', + 'third': 'के तीसरे $days को', + 'fourth': 'के चौथे $days को', + 'fifth': 'के पाँचवें $days को', + 'secondToLast': 'के अंतिम से दूसरे $days को', + 'last': 'के अंतिम $days को', + 'other': 'के $days को', + }); + return '$_temp0'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return '$months में'; } @override String repeatTimesSummary(int count) { - return '$count times'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString बार', + one: '$countString बार', + ); + return '$_temp0'; } @override String repeatUntilSummary(String date) { - return 'until $date'; + return '$date तक'; } @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'यह दोहराव नियम ऐसे विकल्पों का उपयोग करता है जिन्हें यह संपादक नहीं बदलता।'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'इस पुनरावृत्ति का उपयोग $provider के साथ नहीं किया जा सकता।'; + } @override String get importance => 'महत्त्व'; @@ -1339,21 +1666,21 @@ class AppLocalizationsHi extends AppLocalizations { String get subtasks => 'उपकार्य'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'कार्य की प्रतिलिपि बनाएँ'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'कार्य की प्रतिलिपि बन गई।'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'कार्य की प्रतिलिपि नहीं बन सकी: $error'; } @override - String get hideSubtasks => 'Hide subtasks'; + String get hideSubtasks => 'उपकार्य छिपाएँ'; @override - String get hideClosedSubtasks => 'Hide closed subtasks'; + String get hideClosedSubtasks => 'बंद उपकार्य छिपाएँ'; @override String get moveToTop => 'सबसे ऊपर ले जाएँ'; @@ -1409,7 +1736,11 @@ class AppLocalizationsHi extends AppLocalizations { String get sync => 'सिंक'; @override - String get manualFullSync => 'मैन्युअल पूर्ण सिंक'; + String get forceFullResync => 'पूर्ण पुनः सिंक करें'; + + @override + String get forceFullResyncDescription => + 'हर कनेक्ट किए गए खाते से सभी डेटा पूरी तरह फिर से लोड करता है। इसका उपयोग केवल सिंक संबंधी समस्याओं के निवारण के लिए करें।'; @override String get runInBackgroundWhenClosed => 'विंडो बंद होने पर भी चलते रहें'; @@ -1452,7 +1783,10 @@ class AppLocalizationsHi extends AppLocalizations { String get notifyDueToday => 'आज देय कार्यों की सूचनाएँ'; @override - String get eventReminders => 'ईवेंट रिमाइंडर'; + String get eventReminders => 'इवेंट रिमाइंडर'; + + @override + String get onState => 'चालू'; @override String get taskReminders => 'कार्य रिमाइंडर'; @@ -1481,6 +1815,18 @@ class AppLocalizationsHi extends AppLocalizations { @override String get notifications => 'सूचनाएँ'; + @override + String get windowsNotificationsUnavailable => + 'Windows सूचनाएँ उपलब्ध नहीं हैं'; + + @override + String get windowsNotificationsUnpackaged => + 'यह बिना पैकेज वाला डेवलपमेंट रन Windows सूचनाओं का उपयोग नहीं कर सकता। रिमाइंडर जाँचने के लिए परीक्षण-हस्ताक्षरित MSIX इंस्टॉल करें।'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax Windows सूचनाएँ शुरू नहीं कर सका। यह इंस्टॉलेशन समस्या हल होने तक रिमाइंडर दिखाई नहीं देंगे।'; + @override String get appearance => 'दिखावट'; @@ -1490,6 +1836,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get themeSystem => 'सिस्टम'; + @override + String get settingsSystem => 'सिस्टम'; + @override String get themeLight => 'हल्की'; @@ -1566,7 +1915,11 @@ class AppLocalizationsHi extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'प्रयास=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'प्रयास=$countString'; } @override @@ -1611,7 +1964,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return 'एक लंबित स्थानीय बदलाव अवरुद्ध हो गया। $summary'; + return 'लंबित स्थानीय बदलाव अवरुद्ध किया गया। $summary'; } @override @@ -1619,23 +1972,27 @@ class AppLocalizationsHi extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'आज $count कार्य देय हैं।', + other: 'आज $countString कार्य देय हैं।', one: 'आज एक कार्य देय है।', ); return '$_temp0'; } @override - String get eventReminderNotificationTitle => 'ईवेंट रिमाइंडर'; + String get eventReminderNotificationTitle => 'इवेंट रिमाइंडर'; @override String get taskReminderNotificationTitle => 'कार्य रिमाइंडर'; @override - String get eventReminderNotificationBody => 'ईवेंट जल्द शुरू होगा।'; + String get eventReminderNotificationBody => 'इवेंट जल्द शुरू होगा।'; @override String get taskReminderNotificationBody => 'कार्य जल्द देय है।'; @@ -1647,7 +2004,7 @@ class AppLocalizationsHi extends AppLocalizations { String get notificationSnoozeAction => '10 मिनट के लिए स्नूज़ करें'; @override - String get notificationDismissAction => 'खारिज करें'; + String get notificationDismissAction => 'बंद करें'; @override String get notificationDetailsHidden => @@ -1673,7 +2030,11 @@ class AppLocalizationsHi extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'सप्ताह $number'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'सप्ताह $numberString'; } @override @@ -1681,10 +2042,14 @@ class AppLocalizationsHi extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count आइटम', + other: '$countString आइटम', one: '1 आइटम', ); return '$_temp0'; @@ -1703,14 +2068,14 @@ class AppLocalizationsHi extends AppLocalizations { String get noLocationsFound => 'कोई स्थान नहीं मिला'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'यह फ़ील्ड आवश्यक है।'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'इनमें से किसी प्रदाता से कैलेंडर और कार्य कनेक्ट करें।'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Apple iCloud कैलेंडर'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1719,198 +2084,221 @@ class AppLocalizationsHi extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Nextcloud कार्य'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'Apple iCloud Calendar खाता जोड़ें'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Nextcloud खाता जोड़ें'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Apple iCloud से कनेक्ट हो रहा है…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Nextcloud प्राधिकरण की प्रतीक्षा है…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Apple iCloud Calendar कनेक्ट करें'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Apple खाते का ईमेल'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'ऐप-विशिष्ट पासवर्ड'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Apple खाते के लिए दो-चरणीय प्रमाणीकरण चालू करने के बाद ऐप-विशिष्ट पासवर्ड बनाएँ।'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Apple खाते का पासवर्ड रीसेट करने से ऐप-विशिष्ट पासवर्ड रद्द हो जाते हैं।'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Nextcloud कनेक्ट करें'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Nextcloud सर्वर या CalDAV पता'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Nextcloud सर्वर URL दर्ज करें या Nextcloud से कॉपी किया गया प्राथमिक CalDAV पता चिपकाएँ।'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax आपका ब्राउज़र खोलेगा। वहाँ पहुँच की अनुमति दें और फिर BusyMax पर लौटें।'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'कनेक्ट करें'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'कनेक्शन रद्द करें'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'खाता स्थानीय रूप से हटाया गया, लेकिन Nextcloud ऐप पासवर्ड रद्द नहीं किया जा सका।'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'ऑफ़लाइन उपयोग के लिए कैलेंडर और कार्य डेटा स्थानीय रूप से कैश किया जाता है।'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'सिंक फिर से शुरू करने के लिए इस खाते को दोबारा कनेक्ट करें।'; @override String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + 'यह खाता अस्थायी रूप से उपलब्ध नहीं है।'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'सर्वर अनुमतियाँ बदल गई हैं। लंबित संपादन रोक दिए गए हैं।'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'यह सर्वर या प्रदाता प्रोफ़ाइल समर्थित नहीं है।'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'कैलेंडर और कार्य सूचियाँ'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'कैलेंडर इवेंट'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'कार्य'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'केवल-पठन'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'स्थानीय रूप से लंबित'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'विरोध के कारण अवरुद्ध'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'दोबारा कनेक्ट होने तक अवरुद्ध'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'कार्रवाई विफल'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'सर्वर संस्करण रखें'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'स्थानीय बदलाव की समीक्षा कर फिर लागू करें'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'नए आइटम के रूप में कॉपी बनाएँ'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'कनेक्शन की स्थिति'; @override - String get davConnected => 'Connected'; + String get davConnected => 'कनेक्टेड'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'कनेक्ट हो रहा है…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'साइन आउट किया गया'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'अंतिम सफल सिंक: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'अभी तक सिंक नहीं हुआ'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'कैलेंडर और कार्य सूचियाँ रीफ़्रेश करें'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'सर्वर: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'इवेंट कैलेंडर'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'कार्य सूची'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'इवेंट और कार्य'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'लिखने योग्य'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'साझा'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'अंतिम सिंक: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'सिंक समस्या: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'सिंक विरोध'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'सर्वर में बदलाव: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'स्थानीय संपादन: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'विरोध का समाधान नहीं हो सका।'; + + @override + String get recurringEventScope => 'दोहराए जाने वाले इवेंट का दायरा'; + + @override + String get entireSeries => 'पूरी शृंखला'; + + @override + String get singleOccurrence => 'यह इवेंट'; @override - String get recurringEventScope => 'Recurring event scope'; + String get thisAndFollowingEvents => 'यह और इसके बाद के इवेंट'; @override - String get entireSeries => 'Entire series'; + String get thisAndFutureUnavailable => 'यह प्रदाता इसका समर्थन नहीं करता।'; @override - String get singleOccurrence => 'This occurrence'; + String get thisAndFutureMoveUnavailable => + 'इस और इसके बाद के इवेंट सुरक्षित रूप से नहीं ले जाए जा सकते। यह इवेंट या पूरी शृंखला चुनें।'; @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get entireSeriesMoveUnavailable => + 'दोहराव का नियम स्थानीय रूप से उपलब्ध नहीं है। इसके बजाय केवल यह इवेंट ले जाएँ।'; + + @override + String get copyEventAndDeleteOriginal => + 'इवेंट की कॉपी बनाकर मूल इवेंट मिटाएँ?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax इस इवेंट को $source से $destination में सीधे नहीं ले जा सकता। यह पहले कॉपी बनाएगा और कॉपी सफल होने के बाद ही मूल इवेंट मिटाएगा। इवेंट ID बदलेंगे; सहभागियों की प्रतिक्रिया स्थितियां रीसेट हो सकती हैं और आमंत्रण या रद्दीकरण भेजे जा सकते हैं; और कॉन्फ़्रेंस लिंक, अटैचमेंट, रिमाइंडर, प्रदाता-विशिष्ट फ़ील्ड तथा दोहराव अपवाद शायद स्थानांतरित न हों।'; + } + + @override + String get copyAndDelete => 'कॉपी बनाएँ और मिटाएँ'; @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'चुनें कि यह बदलाव पूरी शृंखला, केवल इस इवेंट या इस और इसके बाद के इवेंट पर लागू हो।'; @override String get taskDueBeforeStart => 'नियत समय प्रारंभ समय से पहले नहीं हो सकता।'; @@ -1924,6 +2312,201 @@ class AppLocalizationsHi extends AppLocalizations { return '“$title” मिटाएँ?'; } + @override + String get setCustomCalendarName => 'कस्टम नाम सेट करें'; + + @override + String get setAction => 'सेट करें'; + + @override + String get removeFromMyCalendars => 'मेरे कैलेंडर से हटाएँ'; + + @override + String get removeAction => 'हटाएँ'; + + @override + String removeCalendarConfirmation(String title) { + return 'क्या \"$title\" को आपकी Google Calendar सूची से हटाएँ? साझा कैलेंडर और उसके इवेंट नहीं मिटाए जाएँगे।'; + } + + @override + String get calendarCannotRemove => + 'इस कैलेंडर को इस खाते से हटाया या मिटाया नहीं जा सकता।'; + + @override + String get calendarPendingChangesPreventRemoval => + 'कैलेंडर हटाने से पहले उसके लंबित बदलावों का सिंक पूरा होने की प्रतीक्षा करें।'; + + @override + String get calendarSubscriptions => 'कैलेंडर सदस्यताएँ'; + + @override + String get calendarSubscriptionsDescription => + 'सुरक्षित WebCal URL से रीफ़्रेश होने वाले केवल-पठन कैलेंडर जोड़ें।'; + + @override + String get addCalendarSubscription => 'कैलेंडर सदस्यता जोड़ें'; + + @override + String get subscriptionName => 'स्थानीय नाम'; + + @override + String get subscriptionUrl => 'सदस्यता URL'; + + @override + String get subscriptionUrlHelp => + 'HTTPS या webcal URL दर्ज करें। BusyMax पूरा URL सुरक्षित संग्रहण में रखता है।'; + + @override + String get subscriptionUrlInvalid => + 'उपयोगकर्ता जानकारी या फ़्रैगमेंट के बिना मान्य HTTPS या webcal URL दर्ज करें।'; + + @override + String get subscriptionColor => 'स्थानीय रंग'; + + @override + String get subscriptionColorHelp => + '#3584E4 जैसे छह अंकों वाले रंग का उपयोग करें।'; + + @override + String get subscriptionColorInvalid => + 'छह अंकों वाला हेक्साडेसिमल रंग दर्ज करें।'; + + @override + String get subscriptionRefreshMode => 'रीफ़्रेश आवृत्ति'; + + @override + String get subscriptionAutomatic => 'स्वचालित'; + + @override + String get subscriptionHourly => 'हर घंटे'; + + @override + String get subscriptionSixHours => 'हर छह घंटे'; + + @override + String get subscriptionDaily => 'हर दिन'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'स्रोत: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'सुरक्षित स्रोत का पूर्वावलोकन करने के लिए मान्य URL दर्ज करें।'; + + @override + String get subscriptionReadOnly => 'केवल-पठन सदस्यता'; + + @override + String get subscriptionNeverRefreshed => 'अभी तक रीफ़्रेश नहीं हुआ'; + + @override + String subscriptionLastRefresh(String time) { + return 'अंतिम सफल रीफ़्रेश: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'अगला रीफ़्रेश: $time'; + } + + @override + String get subscriptionStatusHealthy => 'अप टू डेट'; + + @override + String subscriptionStatusIssue(String code) { + return 'रीफ़्रेश समस्या: $code'; + } + + @override + String get refreshNow => 'अभी रीफ़्रेश करें'; + + @override + String get unsubscribe => 'सदस्यता रद्द करें'; + + @override + String unsubscribeCalendarTitle(String name) { + return '“$name” की सदस्यता रद्द करें?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'इससे स्थानीय सदस्यता और कैश किए गए इवेंट हट जाएँगे। प्रकाशित कैलेंडर नहीं बदलेगा।'; + + @override + String get addSubscriptionAction => 'सदस्यता जोड़ें'; + + @override + String subscriptionOperationFailed(String error) { + return 'कैलेंडर सदस्यता विफल: $error'; + } + + @override + String get subscriptions => 'सदस्यताएँ'; + + @override + String get calendarImport => 'कैलेंडर आयात'; + + @override + String get calendarImportDescription => + 'फ़ाइल चुनें, उसके इवेंट की समीक्षा करें और फिर वह लिखने योग्य कैलेंडर चुनें जिसे उन्हें प्राप्त करना चाहिए।'; + + @override + String get importIcsFile => '.ics फ़ाइल आयात करें'; + + @override + String get importIcsPreview => 'कैलेंडर इवेंट आयात करें'; + + @override + String importEventsFound(int count) { + return 'आयात योग्य इवेंट सेट: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'अमान्य इवेंट: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'जानबूझकर छोड़े गए: $fields'; + } + + @override + String get noWritableCalendars => + 'कोई लिखने योग्य गंतव्य कैलेंडर उपलब्ध नहीं है।'; + + @override + String get importDestinationCalendar => 'गंतव्य कैलेंडर'; + + @override + String get importIcsConfirm => 'इवेंट आयात करें'; + + @override + String get importIcsComplete => 'आयात पूरा हुआ'; + + @override + String importQueued(int count) { + return 'आयातित या कतार में: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'डुप्लिकेट छोड़े गए: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'असमर्थित दोहराव सेट: $count'; + } + + @override + String importIcsFailed(String error) { + return 'कैलेंडर फ़ाइल आयात नहीं की जा सकी: $error'; + } + @override String get networkOffline => 'ऑफ़लाइन'; @@ -1934,4 +2517,139 @@ class AppLocalizationsHi extends AppLocalizations { @override String get networkOfflineTryAgain => 'आप ऑफ़लाइन हैं। इंटरनेट से कनेक्ट करें और फिर से कोशिश करें।'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'की $days तारीखों को'; + } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(String day) { + return '$day'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first और $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first और $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first और $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first और $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency $month की $day तारीख को'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency $month की $days तारीखों को'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency $months की $day तारीख को'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency $months की $days तारीखों को'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'पहले', + 'second': 'दूसरे', + 'third': 'तीसरे', + 'fourth': 'चौथे', + 'fifth': 'पाँचवें', + 'secondToLast': 'अंतिम से दूसरे', + 'last': 'अंतिम', + 'other': '', + }); + return '$frequency $month के $_temp0 $days को'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'पहले', + 'second': 'दूसरे', + 'third': 'तीसरे', + 'fourth': 'चौथे', + 'fifth': 'पाँचवें', + 'secondToLast': 'अंतिम से दूसरे', + 'last': 'अंतिम', + 'other': '', + }); + return '$frequency $months के $_temp0 $days को'; + } } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 652d8442..2a39f396 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -12,12 +12,110 @@ import 'app_localizations.dart'; class AppLocalizationsIt extends AppLocalizations { AppLocalizationsIt([String locale = 'it']) : super(locale); + @override + String get windowsSupport => 'Supporto'; + + @override + String get windowsThirdPartyLicenses => 'Licenze di terze parti'; + + @override + String get windowsSearch => 'Cerca'; + + @override + String get windowsStartupDisabledByUser => + 'Disabilitato dall’utente nelle impostazioni di Windows.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Disabilitato da un criterio di Windows.'; + + @override + String get windowsStartupUnavailable => + 'Disponibile dopo l’installazione di BusyMax da un pacchetto MSIX.'; + + @override + String get windowsReminderExitNotice => + 'I promemoria si interrompono quando BusyMax viene chiuso completamente. Lascialo in esecuzione in background per riceverli.'; + + @override + String get windowsProductVersionLabel => 'Versione del prodotto'; + + @override + String get windowsPackageVersionLabel => 'Versione del pacchetto Windows'; + + @override + String get windowsUnpackaged => 'Non pacchettizzato'; + + @override + String get windowsAgendaLoadMore => 'Carica altri elementi dell’agenda'; + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'il lunedì', + 'TU': 'il martedì', + 'WE': 'il mercoledì', + 'TH': 'il giovedì', + 'FR': 'il venerdì', + 'SA': 'il sabato', + 'SU': 'la domenica', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'nei giorni $first e $second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency nei giorni $firstDay e $secondDay di $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency il giorno $day di $firstMonth e $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency nei giorni $firstDay e $secondDay di $firstMonth e $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency nei giorni $days di $firstMonth e $secondMonth'; + } + @override String get appTitle => 'BusyMax'; @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Collega gli account Google, Microsoft, Calendario Apple iCloud o Nextcloud.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +139,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Aggiungi tutti gli account che vuoi usare. BusyMax sincronizza calendari, eventi, elenchi di attività e attività supportati da ogni account.'; @override String get onboardingPreferencesStepTitle => @@ -119,6 +217,44 @@ class AppLocalizationsIt extends AppLocalizations { @override String get calendars => 'Calendari'; + @override + String get newCalendar => 'Nuovo calendario'; + + @override + String get calendarColor => 'Colore del calendario'; + + @override + String calendarColorOption(int number) { + return 'Colore $number'; + } + + @override + String get calendarManagementUnsupported => + 'Questo provider non supporta la gestione dei calendari in BusyMax.'; + + @override + String get primaryCalendarCannotDelete => + 'Il calendario principale non può essere eliminato.'; + + @override + String calendarCreateFailed(String error) { + return 'Impossibile creare il calendario: $error'; + } + + @override + String get calendarCreatedRefreshPending => + 'Il calendario è stato creato, ma BusyMax non ha potuto aggiornare l’account. Apparirà dopo la prossima sincronizzazione.'; + + @override + String calendarUpdateFailed(String error) { + return 'Impossibile aggiornare il calendario: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Impossibile eliminare il calendario: $error'; + } + @override String get newEvent => 'Nuovo evento'; @@ -142,7 +278,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String moreItems(int count) { - return '+$count in più'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString in più'; } @override @@ -183,6 +323,101 @@ class AppLocalizationsIt extends AppLocalizations { @override String get trayOpenBusyMax => 'Apri BusyMax'; + @override + String get trayShowBusyMax => 'Mostra BusyMax'; + + @override + String get trayNewEvent => 'Nuovo evento…'; + + @override + String get trayNewTask => 'Nuova attività…'; + + @override + String get trayToday => 'Oggi'; + + @override + String get trayAllDay => 'Tutto il giorno'; + + @override + String get trayNow => 'Adesso'; + + @override + String get trayCalendarEvent => 'Evento del calendario'; + + @override + String get trayUntitledEvent => 'Evento senza titolo'; + + @override + String get trayNothingElseToday => 'Nient’altro oggi'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count attività scadono oggi', + one: '1 attività scade oggi', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Apri l’agenda di oggi'; + + @override + String get traySyncNow => 'Sincronizza ora'; + + @override + String get traySyncing => 'Sincronizzazione…'; + + @override + String get trayNotConnected => 'Non connesso'; + + @override + String get trayNotYetSynced => 'Non ancora sincronizzato'; + + @override + String get trayLastSyncedJustNow => 'Sincronizzato proprio ora'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Sincronizzato $count minuti fa', + one: 'Sincronizzato 1 minuto fa', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Sincronizzato $count ore fa', + one: 'Sincronizzato 1 ora fa', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Sincronizzato $count giorni fa', + one: 'Sincronizzato 1 giorno fa', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Impostazioni'; + + @override + String get trayQuitBusyMax => 'Esci da BusyMax'; + @override String get agendaLoadMoreOverdue => 'Carica altre attività scadute'; @@ -202,7 +437,7 @@ class AppLocalizationsIt extends AppLocalizations { String get viewYear => 'Anno'; @override - String get viewAgenda => 'Agenda'; + String get viewAgenda => 'Vista agenda'; @override String get scheduleSettings => 'Agenda'; @@ -266,127 +501,127 @@ class AppLocalizationsIt extends AppLocalizations { String get noGuests => 'Nessun invitato'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Obbligatorio'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Facoltativo'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Riunione'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Aggiungi Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Aggiungi riunione Microsoft Teams'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Riunione online aggiunta'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Richiedi risposte'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'Chiedi agli invitati di rispondere all’invito.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Nascondi elenco invitati'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'Gli invitati non possono vedere chi altro è stato invitato.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'Consenti nuove proposte di orario'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Gli invitati possono suggerire un altro orario per la riunione.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'Notificare gli invitati?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'Questa riunione ha degli invitati. Inviare inviti o aggiornamenti dell’evento al salvataggio?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'Questa riunione ha degli invitati. Inviare una cancellazione quando viene eliminata?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Invia aggiornamenti'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Invia cancellazione'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'Non inviare'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'Salvare la riunione?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft invierà inviti o aggiornamenti dell’evento agli invitati.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'Eliminare la riunione?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft invierà una cancellazione agli invitati.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Organizzatore'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'La tua risposta'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Risposte degli invitati'; @override - String get respond => 'Respond'; + String get respond => 'Rispondi'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Accetta'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Provvisorio'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Rifiuta'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'Partecipa alla riunione'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Accettata'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Provvisoria'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Rifiutata'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'In attesa di risposta'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Nessuna risposta'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Organizzatore'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'Impossibile inviare la risposta: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'Impossibile aprire il link della riunione.'; @override String get description => 'Descrizione'; @@ -432,10 +667,14 @@ class AppLocalizationsIt extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes minuti prima', + other: '$minutesString minuti prima', one: '1 minuto prima', ); return '$_temp0'; @@ -446,10 +685,14 @@ class AppLocalizationsIt extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours ore prima', + other: '$hoursString ore prima', one: '1 ora prima', ); return '$_temp0'; @@ -457,10 +700,15 @@ class AppLocalizationsIt extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days giorni prima', + other: '$daysString giorni prima', one: '1 giorno prima', ); return '$_temp0'; @@ -647,7 +895,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Aggiunge soltanto la versione del sistema operativo Linux e le impostazioni locali dell’applicazione. Non vengono inclusi log, dati degli account, nomi di file o altre informazioni diagnostiche.'; + 'Aggiunge soltanto il nome e la versione del sistema operativo e le impostazioni locali dell’applicazione. Non vengono inclusi log, dati degli account, nomi di file o altre informazioni diagnostiche.'; @override String get feedbackCategoryRequired => 'Seleziona una categoria.'; @@ -738,7 +986,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'Questa operazione elimina da questo dispositivo attività, calendari, eventi, promemoria e modifiche offline in sospeso memorizzati nella cache. Le modifiche non sincronizzate andranno perse. Le copie del provider non verranno eliminate.'; @override String get revokeGoogleAccess => @@ -764,17 +1012,17 @@ class AppLocalizationsIt extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'Impossibile creare l’elenco di attività: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'Impossibile rinominare l’elenco di attività: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'Impossibile eliminare l’elenco di attività: $error'; } @override @@ -805,11 +1053,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'Questo elenco di attività è di sola lettura e non può essere rinominato.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'Questo elenco di attività non può essere eliminato con le autorizzazioni attuali.'; @override String get builtInMicrosoftList => 'Integrato'; @@ -820,17 +1068,17 @@ class AppLocalizationsIt extends AppLocalizations { @override String deleteListConfirmation(String title) { - return 'Eliminare «$title» da Google Tasks?'; + return 'Eliminare “$title” da Google Tasks?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return 'Eliminare “$title” e tutte le sue attività?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'Rimuovere la condivisione di “$title” da questo account?'; } @override @@ -958,10 +1206,10 @@ class AppLocalizationsIt extends AppLocalizations { String get doneStatus => 'Completata'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'Stato'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Nessuno stato'; @override String get taskStatusNeedsAction => 'Richiede azione'; @@ -970,60 +1218,79 @@ class AppLocalizationsIt extends AppLocalizations { String get taskStatusInProcess => 'In corso'; @override - String get taskStatusCompleted => 'Completato'; + String get taskStatusCompleted => 'Completata'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Annullata'; @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% completata'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Data di completamento'; @override String get priority => 'Priorità'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Nessuna priorità'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorità $priorityString · alta'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorità $priorityString · media'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorità $priorityString · bassa'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'URL dell’attività'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'Inserisci un URL assoluto, incluso lo schema.'; @override - String get classification => 'Classification'; + String get classification => 'Classificazione'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => + 'Quando condivisa, mostra l’attività completa'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => + 'Quando condivisa, mostra solo lo stato occupato'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => + 'Quando condivisa, nascondi questa attività'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Fissa attività'; @override String get notes => 'Note'; @@ -1062,82 +1329,88 @@ class AppLocalizationsIt extends AppLocalizations { String get addReminder => 'Aggiungi promemoria'; @override - String get reminders => 'Reminders'; + String get reminders => 'Promemoria'; @override String get noReminders => 'Nessun promemoria'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Modifica promemoria'; @override - String get beforeTaskStarts => 'Prima dell\'inizio dell\'attività'; + String get beforeTaskStarts => 'Prima dell’inizio dell’attività'; @override - String get beforeTaskDue => 'Prima della scadenza del compito'; + String get beforeTaskDue => 'Prima della scadenza dell’attività'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'Dopo l’inizio dell’attività'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'Dopo la scadenza dell’attività'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => + 'Rispetto alla data di inizio dell’attività'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => + 'Rispetto alla data di scadenza dell’attività'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Ora del giorno'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'A una data e un’ora'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Quantità'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Unità'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Secondi'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'Minuti'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Ore'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Giorni'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Settimane'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'All’inizio dell’attività'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'Alla scadenza dell’attività'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'Questo tipo di promemoria viene conservato, ma non è possibile modificarne l’ora.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'Conservare i promemoria correlati?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Questa data ha $countString promemoria correlati. Conservarli alla data e all’ora attuali?'; } @override - String get discardRelatedReminders => 'Scarta i promemoria'; + String get discardRelatedReminders => 'Scarta promemoria'; @override - String get keepRelatedReminders => 'Mantieni i promemoria'; + String get keepRelatedReminders => 'Conserva promemoria'; @override String get addGuest => 'Aggiungi invitato'; @@ -1173,91 +1446,125 @@ class AppLocalizationsIt extends AppLocalizations { String get repeatYearly => 'Ogni anno'; @override - String get repeatEvery => 'Ripeti ogni'; + String get repeatEvery => 'Intervallo'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'Ripeti il'; @override String get repeatEnd => 'Termina ripetizione'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'Mai'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'In data'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'Dopo un numero di occorrenze'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'Occorrenze'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'Giorni del mese'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'Mesi'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'Posizione del giorno della settimana'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'Giorni specifici'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'Primo'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'Secondo'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'Terzo'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'Quarto'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'Quinto'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'Penultimo'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'Ultimo'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'Giorno'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'Giorno feriale'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'Giorno del fine settimana'; + + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'lunedì', + 'TU': 'martedì', + 'WE': 'mercoledì', + 'TH': 'giovedì', + 'FR': 'venerdì', + 'SA': 'sabato', + 'SU': 'domenica', + 'day': 'giorno', + 'weekday': 'giorno feriale', + 'weekend': 'giorno del fine settimana', + 'other': '$day', + }); + return '$_temp0'; + } @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Ogni $countString giorni'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Ogni $countString settimane'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Ogni $countString mesi'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Ogni $countString anni'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return '$days'; } @override @@ -1266,18 +1573,38 @@ class AppLocalizationsIt extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'il primo $days', + 'second': 'il secondo $days', + 'third': 'il terzo $days', + 'fourth': 'il quarto $days', + 'fifth': 'il quinto $days', + 'secondToLast': 'il penultimo $days', + 'last': 'l’ultimo $days', + 'other': 'nei giorni $days', + }); + return '$_temp0'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return 'nei mesi $months'; } @override String repeatTimesSummary(int count) { - return '$count volte'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString volte', + one: '$countString volta', + ); + return '$_temp0'; } @override @@ -1287,7 +1614,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'Questa regola di ricorrenza usa opzioni che questo editor non modifica.'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'Questa ricorrenza non può essere usata con $provider.'; + } @override String get importance => 'Importanza'; @@ -1342,21 +1674,21 @@ class AppLocalizationsIt extends AppLocalizations { String get subtasks => 'Sottoattività'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'Duplica attività'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Attività duplicata.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'Impossibile duplicare l’attività: $error'; } @override String get hideSubtasks => 'Nascondi sottoattività'; @override - String get hideClosedSubtasks => 'Nascondi sotto-attività chiuse'; + String get hideClosedSubtasks => 'Nascondi sottoattività chiuse'; @override String get moveToTop => 'Sposta in cima'; @@ -1406,13 +1738,17 @@ class AppLocalizationsIt extends AppLocalizations { String get synced => 'Sincronizzato'; @override - String get account => 'Account'; + String get account => 'Profilo'; @override String get sync => 'Sincronizzazione'; @override - String get manualFullSync => 'Sincronizzazione completa manuale'; + String get forceFullResync => 'Forza una risincronizzazione completa'; + + @override + String get forceFullResyncDescription => + 'Ricarica completamente i dati di ogni account connesso. Usa questa opzione solo per risolvere problemi di sincronizzazione.'; @override String get runInBackgroundWhenClosed => @@ -1460,6 +1796,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get eventReminders => 'Promemoria degli eventi'; + @override + String get onState => 'Attivato'; + @override String get taskReminders => 'Promemoria delle attività'; @@ -1488,6 +1827,18 @@ class AppLocalizationsIt extends AppLocalizations { @override String get notifications => 'Notifiche'; + @override + String get windowsNotificationsUnavailable => + 'Le notifiche di Windows non sono disponibili'; + + @override + String get windowsNotificationsUnpackaged => + 'Questa esecuzione di sviluppo non pacchettizzata non può usare le notifiche di Windows. Installa il pacchetto MSIX firmato per i test per verificare i promemoria.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax non ha potuto inizializzare le notifiche di Windows. I promemoria non appariranno finché il problema di installazione non sarà risolto.'; + @override String get appearance => 'Aspetto'; @@ -1497,6 +1848,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get themeSystem => 'Sistema'; + @override + String get settingsSystem => 'Sistema'; + @override String get themeLight => 'Chiaro'; @@ -1516,7 +1870,7 @@ class AppLocalizationsIt extends AppLocalizations { String get currentLocale => 'Impostazioni locali correnti'; @override - String get privacy => 'Privacy'; + String get privacy => 'Riservatezza'; @override String get redactTaskContentInDiagnostics => @@ -1575,7 +1929,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'tentativi=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'tentativi=$countString'; } @override @@ -1630,10 +1988,14 @@ class AppLocalizationsIt extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count attività scadono oggi.', + other: '$countString attività scadono oggi.', one: 'Un’attività scade oggi.', ); return '$_temp0'; @@ -1658,7 +2020,7 @@ class AppLocalizationsIt extends AppLocalizations { String get notificationSnoozeAction => 'Posticipa di 10 minuti'; @override - String get notificationDismissAction => 'Ignora'; + String get notificationDismissAction => 'Chiudi'; @override String get notificationDetailsHidden => @@ -1684,7 +2046,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'Settimana $number'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Settimana $numberString'; } @override @@ -1693,10 +2059,14 @@ class AppLocalizationsIt extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count elementi', + other: '$countString elementi', one: '1 elemento', ); return '$_temp0'; @@ -1709,20 +2079,20 @@ class AppLocalizationsIt extends AppLocalizations { String get selectTimeZone => 'Seleziona fuso orario'; @override - String get searchLocations => 'Cerca luoghi'; + String get searchLocations => 'Cerca località'; @override - String get noLocationsFound => 'Nessun luogo trovato'; + String get noLocationsFound => 'Nessuna località trovata'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'Questo campo è obbligatorio.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Collega calendari e attività a uno di questi provider.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Calendario Apple iCloud'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1731,205 +2101,229 @@ class AppLocalizationsIt extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Attività Nextcloud'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => + 'Aggiungi account Calendario Apple iCloud'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Aggiungi account Nextcloud'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Connessione ad Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'In attesa dell’autorizzazione Nextcloud…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Collega Calendario Apple iCloud'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'E-mail dell’account Apple'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'Password specifica per l’app'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Crea una password specifica per l’app dopo aver attivato l’autenticazione a due fattori per il tuo account Apple.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'La reimpostazione della password dell’account Apple revoca le password specifiche per le app.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Collega Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Server Nextcloud o indirizzo CalDAV'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Inserisci l’URL del server Nextcloud o incolla l’indirizzo CalDAV principale copiato da Nextcloud.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax aprirà il browser. Approva l’accesso e torna a BusyMax.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Collega'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Annulla connessione'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'L’account è stato rimosso localmente, ma non è stato possibile revocare la password dell’app Nextcloud.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'I dati di calendari e attività vengono memorizzati localmente per l’uso offline.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Ricollega questo account per riprendere la sincronizzazione.'; @override String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + 'Questo account è temporaneamente non disponibile.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'Le autorizzazioni del server sono cambiate. Le modifiche in sospeso sono in pausa.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Questo server o profilo provider non è supportato.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Calendari ed elenchi di attività'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'Eventi del calendario'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Attività'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Sola lettura'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'In sospeso localmente'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Bloccato da un conflitto'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'Bloccato fino alla riconnessione'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'Operazione non riuscita'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Mantieni la versione del server'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'Esamina e riapplica la modifica locale'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Duplica come nuovo elemento'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'Stato della connessione'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Connesso'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'Connessione…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Disconnesso'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Ultima sincronizzazione riuscita: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Non ancora sincronizzato'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'Aggiorna calendari ed elenchi di attività'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Indirizzo server: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Calendario eventi'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Elenco di attività'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'Eventi e attività'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Scrivibile'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Condiviso'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Ultima sincronizzazione: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Problema di sincronizzazione: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Conflitti di sincronizzazione'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Modifica del server: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Modifica locale: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'Impossibile risolvere il conflitto.'; + + @override + String get recurringEventScope => 'Ambito dell’evento ricorrente'; + + @override + String get entireSeries => 'Intera serie'; + + @override + String get singleOccurrence => 'Questo evento'; @override - String get recurringEventScope => 'Recurring event scope'; + String get thisAndFollowingEvents => 'Questo evento e quelli successivi'; @override - String get entireSeries => 'Entire series'; + String get thisAndFutureUnavailable => 'Non supportato da questo provider.'; @override - String get singleOccurrence => 'This occurrence'; + String get thisAndFutureMoveUnavailable => + 'Questo evento e quelli successivi non possono essere spostati in modo sicuro. Scegli questo evento o l’intera serie.'; @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get entireSeriesMoveUnavailable => + 'La regola di ricorrenza non è disponibile localmente. Sposta invece solo questo evento.'; + + @override + String get copyEventAndDeleteOriginal => + 'Copiare l’evento ed eliminare l’originale?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax non può spostare direttamente questo evento da $source a $destination. Creerà prima la copia ed eliminerà l’originale solo dopo che la copia sarà riuscita. Gli ID dell’evento cambieranno; gli stati di risposta dei partecipanti potrebbero essere reimpostati e potrebbero essere inviati inviti o annullamenti; i link alle riunioni, gli allegati, i promemoria, i campi specifici del provider e le eccezioni di ricorrenza potrebbero non essere trasferiti.'; + } + + @override + String get copyAndDelete => 'Copia ed elimina'; @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Scegli se questa modifica si applica all’intera serie, solo a questo evento oppure a questo e agli eventi successivi.'; @override String get taskDueBeforeStart => 'La scadenza non può precedere l\'inizio.'; @override String get taskStartDueTimeModeMismatch => - 'Imposta un orario sia per l\'inizio sia per la scadenza, oppure rendi l\'attività valida per l\'intera giornata.'; + 'Imposta un orario sia per l’inizio sia per la scadenza, oppure rendi l’attività valida per l’intera giornata.'; @override String deleteCalendarConfirmation(String title) { @@ -1937,7 +2331,201 @@ class AppLocalizationsIt extends AppLocalizations { } @override - String get networkOffline => 'Offline'; + String get setCustomCalendarName => 'Imposta nome personalizzato'; + + @override + String get setAction => 'Imposta'; + + @override + String get removeFromMyCalendars => 'Rimuovi dai miei calendari'; + + @override + String get removeAction => 'Rimuovi'; + + @override + String removeCalendarConfirmation(String title) { + return 'Rimuovere \"$title\" dall\'elenco di Google Calendar? Il calendario condiviso e i relativi eventi non verranno eliminati.'; + } + + @override + String get calendarCannotRemove => + 'Non è possibile eliminare o rimuovere questo calendario da questo account.'; + + @override + String get calendarPendingChangesPreventRemoval => + 'Attendi il completamento della sincronizzazione delle modifiche in sospeso del calendario prima di eliminarlo o rimuoverlo.'; + + @override + String get calendarSubscriptions => 'Abbonamenti ai calendari'; + + @override + String get calendarSubscriptionsDescription => + 'Aggiungi calendari di sola lettura che si aggiornano da un URL WebCal sicuro.'; + + @override + String get addCalendarSubscription => 'Aggiungi abbonamento al calendario'; + + @override + String get subscriptionName => 'Nome locale'; + + @override + String get subscriptionUrl => 'URL abbonamento'; + + @override + String get subscriptionUrlHelp => + 'Inserisci un URL HTTPS o webcal. BusyMax conserva l’URL completo in un archivio sicuro.'; + + @override + String get subscriptionUrlInvalid => + 'Inserisci un URL HTTPS o webcal valido senza informazioni utente o frammento.'; + + @override + String get subscriptionColor => 'Colore locale'; + + @override + String get subscriptionColorHelp => 'Usa un colore a sei cifre come #3584E4.'; + + @override + String get subscriptionColorInvalid => + 'Inserisci un colore esadecimale di sei cifre.'; + + @override + String get subscriptionRefreshMode => 'Frequenza di aggiornamento'; + + @override + String get subscriptionAutomatic => 'Automatica'; + + @override + String get subscriptionHourly => 'Ogni ora'; + + @override + String get subscriptionSixHours => 'Ogni sei ore'; + + @override + String get subscriptionDaily => 'Ogni giorno'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Origine: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Inserisci un URL valido per visualizzarne l’origine sicura.'; + + @override + String get subscriptionReadOnly => 'Abbonamento di sola lettura'; + + @override + String get subscriptionNeverRefreshed => 'Non ancora aggiornato'; + + @override + String subscriptionLastRefresh(String time) { + return 'Ultimo aggiornamento riuscito: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Prossimo aggiornamento: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Aggiornato'; + + @override + String subscriptionStatusIssue(String code) { + return 'Problema di aggiornamento: $code'; + } + + @override + String get refreshNow => 'Aggiorna ora'; + + @override + String get unsubscribe => 'Annulla abbonamento'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Annullare l’abbonamento a “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'Rimuove l’abbonamento locale e gli eventi memorizzati nella cache. Il calendario pubblicato non cambia.'; + + @override + String get addSubscriptionAction => 'Aggiungi abbonamento'; + + @override + String subscriptionOperationFailed(String error) { + return 'Abbonamento al calendario non riuscito: $error'; + } + + @override + String get subscriptions => 'Abbonamenti'; + + @override + String get calendarImport => 'Importazione calendario'; + + @override + String get calendarImportDescription => + 'Seleziona un file, rivedi gli eventi e scegli il calendario scrivibile che deve riceverli.'; + + @override + String get importIcsFile => 'Importa file .ics'; + + @override + String get importIcsPreview => 'Importa eventi del calendario'; + + @override + String importEventsFound(int count) { + return 'Insiemi di eventi importabili: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Eventi non validi: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Ommessi intenzionalmente: $fields'; + } + + @override + String get noWritableCalendars => + 'Nessun calendario di destinazione scrivibile disponibile.'; + + @override + String get importDestinationCalendar => 'Calendario di destinazione'; + + @override + String get importIcsConfirm => 'Importa eventi'; + + @override + String get importIcsComplete => 'Importazione completata'; + + @override + String importQueued(int count) { + return 'Importati o accodati: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicati ignorati: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Insiemi di ricorrenze non supportati: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Impossibile importare il file del calendario: $error'; + } + + @override + String get networkOffline => 'Senza connessione'; @override String get networkOfflineDescription => @@ -1946,4 +2534,139 @@ class AppLocalizationsIt extends AppLocalizations { @override String get networkOfflineTryAgain => 'Sei offline. Connettiti a Internet e riprova.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'i giorni $days'; + } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(String day) { + return '$day'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first e $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first e $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first e $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first e $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency il giorno $day di $month'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency nei giorni $days di $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency il giorno $day di $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency nei giorni $days di $months'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'il primo $days di $month', + 'second': 'il secondo $days di $month', + 'third': 'il terzo $days di $month', + 'fourth': 'il quarto $days di $month', + 'fifth': 'il quinto $days di $month', + 'secondToLast': 'il penultimo $days di $month', + 'last': 'l’ultimo $days di $month', + 'other': 'nei giorni $days di $month', + }); + return '$frequency $_temp0'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'il primo $days di $months', + 'second': 'il secondo $days di $months', + 'third': 'il terzo $days di $months', + 'fourth': 'il quarto $days di $months', + 'fifth': 'il quinto $days di $months', + 'secondToLast': 'il penultimo $days di $months', + 'last': 'l’ultimo $days di $months', + 'other': 'nei giorni $days di $months', + }); + return '$frequency $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 09996619..6083ba6f 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -12,12 +12,108 @@ import 'app_localizations.dart'; class AppLocalizationsJa extends AppLocalizations { AppLocalizationsJa([String locale = 'ja']) : super(locale); + @override + String get windowsSupport => 'サポート'; + + @override + String get windowsThirdPartyLicenses => 'サードパーティ ライセンス'; + + @override + String get windowsSearch => '検索'; + + @override + String get windowsStartupDisabledByUser => 'Windows の設定でユーザーにより無効にされています。'; + + @override + String get windowsStartupDisabledByPolicy => 'Windows ポリシーにより無効にされています。'; + + @override + String get windowsStartupUnavailable => + 'BusyMax を MSIX パッケージからインストールすると利用できます。'; + + @override + String get windowsReminderExitNotice => + 'BusyMax を完全に終了するとリマインダーも停止します。受け取るにはバックグラウンドで実行したままにしてください。'; + + @override + String get windowsProductVersionLabel => '製品バージョン'; + + @override + String get windowsPackageVersionLabel => 'Windows パッケージ バージョン'; + + @override + String get windowsUnpackaged => 'パッケージ化されていません'; + + @override + String get windowsAgendaLoadMore => '予定をさらに読み込む'; + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '月曜日', + 'TU': '火曜日', + 'WE': '水曜日', + 'TH': '木曜日', + 'FR': '金曜日', + 'SA': '土曜日', + 'SU': '日曜日', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return '$first、$second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency$monthの$firstDayと$secondDay'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency$firstMonthと$secondMonthの$day'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency$firstMonthと$secondMonthの$firstDayと$secondDay'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency$firstMonthと$secondMonthの$days'; + } + @override String get appTitle => 'BusyMax'; @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Google、Microsoft、Apple iCloud Calendar、または Nextcloud のアカウントを接続します。'; @override String get googlePermissionsConsentNotice => @@ -41,7 +137,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + '使用するすべてのアカウントを追加します。BusyMax は各アカウントの対応するカレンダー、予定、タスクリスト、タスクを同期します。'; @override String get onboardingPreferencesStepTitle => 'システム設定を選択'; @@ -117,6 +213,43 @@ class AppLocalizationsJa extends AppLocalizations { @override String get calendars => 'カレンダー'; + @override + String get newCalendar => '新しいカレンダー'; + + @override + String get calendarColor => 'カレンダーの色'; + + @override + String calendarColorOption(int number) { + return '色 $number'; + } + + @override + String get calendarManagementUnsupported => + 'このプロバイダーのカレンダー管理は BusyMax ではサポートされていません。'; + + @override + String get primaryCalendarCannotDelete => 'メインカレンダーは削除できません。'; + + @override + String calendarCreateFailed(String error) { + return 'カレンダーを作成できませんでした: $error'; + } + + @override + String get calendarCreatedRefreshPending => + 'カレンダーは作成されましたが、BusyMax はアカウントを更新できませんでした。次回の同期後に表示されます。'; + + @override + String calendarUpdateFailed(String error) { + return 'カレンダーを更新できませんでした: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'カレンダーを削除できませんでした: $error'; + } + @override String get newEvent => '新しい予定'; @@ -140,7 +273,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String moreItems(int count) { - return '他 $count 件'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '他 $countString 件'; } @override @@ -177,6 +314,101 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trayOpenBusyMax => 'BusyMax を開く'; + @override + String get trayShowBusyMax => 'BusyMax を表示'; + + @override + String get trayNewEvent => '新しい予定…'; + + @override + String get trayNewTask => '新しいタスク…'; + + @override + String get trayToday => '今日'; + + @override + String get trayAllDay => '終日'; + + @override + String get trayNow => '今'; + + @override + String get trayCalendarEvent => 'カレンダーの予定'; + + @override + String get trayUntitledEvent => '無題の予定'; + + @override + String get trayNothingElseToday => '今日はこれ以上ありません'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '今日が期限のタスクは $count 件', + one: '今日が期限のタスクは 1 件', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => '今日の予定表を開く'; + + @override + String get traySyncNow => '今すぐ同期'; + + @override + String get traySyncing => '同期中…'; + + @override + String get trayNotConnected => '未接続'; + + @override + String get trayNotYetSynced => 'まだ同期されていません'; + + @override + String get trayLastSyncedJustNow => 'たった今同期'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 分前に同期', + one: '1 分前に同期', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 時間前に同期', + one: '1 時間前に同期', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 日前に同期', + one: '1 日前に同期', + ); + return '$_temp0'; + } + + @override + String get traySettings => '設定'; + + @override + String get trayQuitBusyMax => 'BusyMax を終了'; + @override String get agendaLoadMoreOverdue => '期限切れのタスクをさらに読み込む'; @@ -260,127 +492,122 @@ class AppLocalizationsJa extends AppLocalizations { String get noGuests => 'ゲストなし'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => '必須'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => '任意'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => '会議'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Google Meet を追加'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Microsoft Teams 会議を追加'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'オンライン会議を追加しました'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => '返信をリクエスト'; @override - String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + String get requestResponsesDescription => 'ゲストに招待への返信を依頼します。'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'ゲストリストを非表示'; @override - String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + String get hideGuestListDescription => 'ゲストには他の招待者が表示されません。'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => '新しい日時の提案を許可'; @override - String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + String get allowNewTimeProposalsDescription => 'ゲストは別の会議時間を提案できます。'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'ゲストに通知しますか?'; @override - String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + String get notifyGuestsSaveMessage => 'この会議にはゲストがいます。保存時に招待または予定の更新を送信しますか?'; @override - String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + String get notifyGuestsDeleteMessage => 'この会議にはゲストがいます。削除時にキャンセルを送信しますか?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => '更新を送信'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'キャンセルを送信'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => '送信しない'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => '会議を保存しますか?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft からゲストに招待または予定の更新が送信されます。'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => '会議を削除しますか?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft からゲストにキャンセルが送信されます。'; @override - String get organizer => 'Organizer'; + String get organizer => '主催者'; @override - String get yourResponse => 'Your response'; + String get yourResponse => '自分の返信'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'ゲストの返信'; @override - String get respond => 'Respond'; + String get respond => '返信'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => '承諾'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => '仮承諾'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => '辞退'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => '会議に参加'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => '承諾済み'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => '仮承諾'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => '辞退済み'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => '返信待ち'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => '未返信'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => '主催者'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return '返信を送信できませんでした: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => '会議リンクを開けませんでした。'; @override String get description => '説明'; @@ -426,10 +653,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes分前', + other: '$minutesString分前', one: '1分前', ); return '$_temp0'; @@ -440,10 +671,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours時間前', + other: '$hoursString時間前', one: '1時間前', ); return '$_temp0'; @@ -451,10 +686,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days日前', + other: '$daysString日前', one: '1日前', ); return '$_temp0'; @@ -636,7 +876,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Linux オペレーティングシステムのバージョンとアプリのロケールのみが追加されます。ログ、アカウントデータ、ファイル名、その他の診断情報は含まれません。'; + 'オペレーティングシステムの名前とバージョン、およびアプリのロケールのみが追加されます。ログ、アカウントデータ、ファイル名、その他の診断情報は含まれません。'; @override String get feedbackCategoryRequired => 'カテゴリーを選択してください。'; @@ -718,12 +958,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String removeAccountTitle(String account) { - return 'BusyMax から $account を削除しますか?'; + return '$account を BusyMax から削除しますか?'; } @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'このデバイスから、キャッシュされたタスク、カレンダー、予定、リマインダー、保留中のオフライン変更を削除します。同期されていない変更は失われます。プロバイダー側のカレンダー、予定、タスクリスト、タスクのコピーは削除されません。'; @override String get revokeGoogleAccess => 'この Google アカウントへの BusyMax のアクセス権も取り消す'; @@ -746,17 +986,17 @@ class AppLocalizationsJa extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'タスクリストを作成できませんでした: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'タスクリストの名前を変更できませんでした: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'タスクリストを削除できませんでした: $error'; } @override @@ -784,12 +1024,10 @@ class AppLocalizationsJa extends AppLocalizations { String get unshare => '共有を解除'; @override - String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + String get readOnlyTaskListCannotRename => 'このタスクリストは読み取り専用のため、名前を変更できません。'; @override - String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + String get taskListCannotDelete => '現在の権限ではこのタスクリストを削除できません。'; @override String get builtInMicrosoftList => '組み込み'; @@ -805,12 +1043,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return '「$title」とそのすべてのタスクを削除しますか?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'このアカウントとの「$title」の共有を解除しますか?'; } @override @@ -935,13 +1173,13 @@ class AppLocalizationsJa extends AppLocalizations { String get doneStatus => '完了'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'ステータス'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'ステータスなし'; @override - String get taskStatusNeedsAction => 'アクションが必要'; + String get taskStatusNeedsAction => '要対応'; @override String get taskStatusInProcess => '進行中'; @@ -950,57 +1188,73 @@ class AppLocalizationsJa extends AppLocalizations { String get taskStatusCompleted => '完了'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'キャンセル済み'; @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% 完了'; } @override - String get completionDate => 'Completion date'; + String get completionDate => '完了日'; @override String get priority => '優先度'; @override - String get priorityNone => 'No priority'; + String get priorityNone => '優先度なし'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '優先度 $priorityString · 高'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '優先度 $priorityString · 中'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '優先度 $priorityString · 低'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'タスク URL'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'スキームを含む絶対 URL を入力してください。'; @override - String get classification => 'Classification'; + String get classification => '分類'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => '共有時にタスク全体を表示'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => '共有時に空き状況のみ表示'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => '共有時にこのタスクを非表示'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'タスクをピン留め'; @override String get notes => 'メモ'; @@ -1039,82 +1293,85 @@ class AppLocalizationsJa extends AppLocalizations { String get addReminder => 'リマインダーを追加'; @override - String get reminders => 'Reminders'; + String get reminders => 'リマインダー'; @override - String get noReminders => 'No reminders'; + String get noReminders => 'リマインダーなし'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'リマインダーを編集'; @override - String get beforeTaskStarts => 'Before the task starts'; + String get beforeTaskStarts => 'タスクの開始前'; @override - String get beforeTaskDue => 'Before the task is due'; + String get beforeTaskDue => 'タスクの期限前'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'タスクの開始後'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'タスクの期限後'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'タスクの開始日に対して'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'タスクの期限日に対して'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => '時刻'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => '日時を指定'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => '数値'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => '単位'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => '秒'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => '分'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => '時間'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => '日'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => '週'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'タスクの開始時'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'タスクの期限時刻'; @override - String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + String get unsupportedReminder => 'このリマインダーの種類は保持されますが、時刻は編集できません。'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => '関連するリマインダーを保持しますか?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'この日付には関連するリマインダーが $countString 件あります。現在の日付と時刻を保持しますか?'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => 'リマインダーを破棄'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => 'リマインダーを保持'; @override String get addGuest => 'ゲストを追加'; @@ -1150,121 +1407,179 @@ class AppLocalizationsJa extends AppLocalizations { String get repeatYearly => '毎年'; @override - String get repeatEvery => '毎日繰り返す'; + String get repeatEvery => '繰り返し間隔'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => '繰り返す曜日'; @override - String get repeatEnd => '繰り返し終了'; + String get repeatEnd => '繰り返しを終了'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'なし'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => '指定日まで'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => '指定回数後'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => '繰り返し回数'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => '月の日'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => '月'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => '曜日の順番'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => '特定の曜日'; @override - String get repeatFirst => 'First'; + String get repeatFirst => '第 1'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => '第 2'; @override - String get repeatThird => 'Third'; + String get repeatThird => '第 3'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => '第 4'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => '第 5'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => '最後から 2 番目'; @override - String get repeatLast => 'Last'; + String get repeatLast => '最後'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => '日'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => '平日'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => '週末'; + + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '月曜日', + 'TU': '火曜日', + 'WE': '水曜日', + 'TH': '木曜日', + 'FR': '金曜日', + 'SA': '土曜日', + 'SU': '日曜日', + 'day': '日', + 'weekday': '平日', + 'weekend': '週末', + 'other': '$day', + }); + return '$_temp0'; + } @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '$countString 日ごと'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '$countString 週間ごと'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '$countString か月ごと'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '$countString 年ごと'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return '$days'; } @override String repeatOnMonthDaysSummary(String days) { - return 'on day $days'; + return '$days'; } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第1$days', + 'second': '第2$days', + 'third': '第3$days', + 'fourth': '第4$days', + 'fifth': '第5$days', + 'secondToLast': '最後から2番目の$days', + 'last': '最後の$days', + 'other': '$days', + }); + return '$_temp0'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return '$months に'; } @override String repeatTimesSummary(int count) { - return '$count回'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString 回', + ); + return '$_temp0'; } @override String repeatUntilSummary(String date) { - return '$dateまで'; + return '$date まで'; } @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'この繰り返しルールには、このエディターで変更されないオプションが含まれています。'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'この繰り返しは $provider では使用できません。'; + } @override String get importance => '重要度'; @@ -1319,21 +1634,21 @@ class AppLocalizationsJa extends AppLocalizations { String get subtasks => 'サブタスク'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'タスクを複製'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'タスクを複製しました。'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'タスクを複製できませんでした: $error'; } @override String get hideSubtasks => 'サブタスクを非表示'; @override - String get hideClosedSubtasks => 'Hide closed subtasks'; + String get hideClosedSubtasks => '完了したサブタスクを非表示'; @override String get moveToTop => '一番上に移動'; @@ -1389,7 +1704,11 @@ class AppLocalizationsJa extends AppLocalizations { String get sync => '同期'; @override - String get manualFullSync => '手動ですべて同期'; + String get forceFullResync => '完全な再同期を強制'; + + @override + String get forceFullResyncDescription => + '接続されているすべてのアカウントからデータを完全に再読み込みします。同期の問題を解決する場合にのみ使用してください。'; @override String get runInBackgroundWhenClosed => 'ウィンドウを閉じてもバックグラウンドで実行を続ける'; @@ -1433,6 +1752,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get eventReminders => '予定のリマインダー'; + @override + String get onState => 'オン'; + @override String get taskReminders => 'タスクのリマインダー'; @@ -1460,6 +1782,17 @@ class AppLocalizationsJa extends AppLocalizations { @override String get notifications => '通知'; + @override + String get windowsNotificationsUnavailable => 'Windows 通知を利用できません'; + + @override + String get windowsNotificationsUnpackaged => + 'このパッケージ化されていない開発実行では Windows 通知を使用できません。リマインダーをテストするには、テスト署名済み MSIX をインストールしてください。'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax は Windows 通知を初期化できませんでした。このインストールの問題が解決するまで、リマインダーは表示されません。'; + @override String get appearance => '外観'; @@ -1469,6 +1802,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get themeSystem => 'システム'; + @override + String get settingsSystem => 'システム'; + @override String get themeLight => 'ライト'; @@ -1543,7 +1879,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String pendingOpAttempts(int count) { - return '試行回数=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '試行回数=$countString'; } @override @@ -1595,10 +1935,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '今日が期限のタスクが$count件あります。', + other: '今日が期限のタスクが$countString件あります。', one: '今日が期限のタスクが1件あります。', ); return '$_temp0'; @@ -1648,7 +1992,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String weekNumberTooltip(int number) { - return '第$number週'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return '第$numberString週'; } @override @@ -1656,10 +2004,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count件', + other: '$countString件', one: '1件', ); return '$_temp0'; @@ -1678,14 +2030,13 @@ class AppLocalizationsJa extends AppLocalizations { String get noLocationsFound => '場所が見つかりません'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'このフィールドは必須です。'; @override - String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + String get providerConnectionDescription => '次のいずれかのプロバイダーからカレンダーとタスクを接続します。'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Apple iCloud カレンダー'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1694,198 +2045,216 @@ class AppLocalizationsJa extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Nextcloud タスク'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'Apple iCloud カレンダーアカウントを追加'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Nextcloud アカウントを追加'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Apple iCloud に接続中…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Nextcloud の認証を待機中…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Apple iCloud カレンダーを接続'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Apple アカウントのメールアドレス'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'App 用パスワード'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Apple アカウントで 2 要素認証を有効にした後、App 用パスワードを作成してください。'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Apple アカウントのパスワードをリセットすると、App 用パスワードが無効になります。'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Nextcloud を接続'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Nextcloud サーバーまたは CalDAV アドレス'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Nextcloud サーバーの URL を入力するか、Nextcloud からコピーしたプライマリ CalDAV アドレスを貼り付けてください。'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax がブラウザーを開きます。そこでアクセスを許可してから BusyMax に戻ってください。'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => '接続'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => '接続をキャンセル'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'アカウントはローカルから削除されましたが、Nextcloud の App パスワードを無効にできませんでした。'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'オフラインで使用できるよう、カレンダーとタスクのデータはローカルにキャッシュされます。'; @override - String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + String get davReauthenticationRequired => '同期を再開するには、このアカウントを再接続してください。'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get davTemporarilyUnavailable => 'このアカウントは一時的に利用できません。'; @override - String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + String get davPermissionChanged => 'サーバーの権限が変更されました。保留中の編集は一時停止されています。'; @override - String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + String get davUnsupportedServer => 'このサーバーまたはプロバイダー プロファイルはサポートされていません。'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'カレンダーとタスクリスト'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'カレンダーの予定'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'タスク'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => '読み取り専用'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'ローカルで保留中'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => '競合によりブロック'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => '再接続するまでブロック'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => '操作に失敗'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'サーバーのバージョンを保持'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'ローカル変更を確認して再適用'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => '新しいアイテムとして複製'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => '接続状態'; @override - String get davConnected => 'Connected'; + String get davConnected => '接続済み'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => '接続中…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'サインアウト済み'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return '最後に成功した同期: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'まだ同期されていません'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'カレンダーとタスクリストを更新'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'サーバー: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => '予定カレンダー'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'タスクリスト'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => '予定とタスク'; @override - String get writableCollection => 'Writable'; + String get writableCollection => '書き込み可能'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => '共有'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return '最後の同期: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return '同期の問題: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => '同期の競合'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'サーバーでの変更: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'ローカル編集: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => '競合を解決できませんでした。'; + + @override + String get recurringEventScope => '繰り返し予定の範囲'; + + @override + String get entireSeries => '予定全体'; + + @override + String get singleOccurrence => 'この予定'; @override - String get recurringEventScope => 'Recurring event scope'; + String get thisAndFollowingEvents => 'この予定とこれ以降の予定'; @override - String get entireSeries => 'Entire series'; + String get thisAndFutureUnavailable => 'このプロバイダーではサポートされていません。'; @override - String get singleOccurrence => 'This occurrence'; + String get thisAndFutureMoveUnavailable => + 'この予定とこれ以降の予定を安全に移動することはできません。この予定または予定全体を選択してください。'; @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get entireSeriesMoveUnavailable => + '繰り返しルールがローカルにありません。代わりにこの予定だけを移動してください。'; + + @override + String get copyEventAndDeleteOriginal => '予定をコピーして元の予定を削除しますか?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax はこの予定を $source から $destination へ直接移動できません。先にコピーを作成し、コピーが成功した場合にのみ元の予定を削除します。予定 ID は変更されます。出席者の回答状況がリセットされ、招待やキャンセルが送信される場合があります。また、会議リンク、添付ファイル、リマインダー、プロバイダー固有のフィールド、繰り返しの例外は引き継がれない場合があります。'; + } + + @override + String get copyAndDelete => 'コピーして削除'; @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'この変更を予定全体、この予定のみ、この予定とこれ以降の予定のどれに適用するか選択してください。'; @override String get taskDueBeforeStart => '期限を開始より前に設定することはできません。'; @@ -1898,6 +2267,197 @@ class AppLocalizationsJa extends AppLocalizations { return '「$title」を削除しますか?'; } + @override + String get setCustomCalendarName => 'カスタム名を設定'; + + @override + String get setAction => '設定'; + + @override + String get removeFromMyCalendars => 'マイカレンダーから削除'; + + @override + String get removeAction => '削除'; + + @override + String removeCalendarConfirmation(String title) { + return '「$title」を Google カレンダーのリストから削除しますか?共有カレンダーとその予定は削除されません。'; + } + + @override + String get calendarCannotRemove => 'このカレンダーはこのアカウントから削除または解除できません。'; + + @override + String get calendarPendingChangesPreventRemoval => + '削除または解除する前に、このカレンダーの保留中の変更が同期されるまで待ってください。'; + + @override + String get calendarSubscriptions => 'カレンダーの登録'; + + @override + String get calendarSubscriptionsDescription => + '安全な WebCal URL から更新される読み取り専用カレンダーを追加します。'; + + @override + String get addCalendarSubscription => 'カレンダー登録を追加'; + + @override + String get subscriptionName => 'ローカル名'; + + @override + String get subscriptionUrl => '登録 URL'; + + @override + String get subscriptionUrlHelp => + 'HTTPS または webcal URL を入力してください。BusyMax は完全な URL を安全なストレージに保存します。'; + + @override + String get subscriptionUrlInvalid => + 'ユーザー情報やフラグメントを含まない有効な HTTPS または webcal URL を入力してください。'; + + @override + String get subscriptionColor => 'ローカルの色'; + + @override + String get subscriptionColorHelp => '#3584E4 のような 6 桁の色を使用してください。'; + + @override + String get subscriptionColorInvalid => '6 桁の 16 進数カラーを入力してください。'; + + @override + String get subscriptionRefreshMode => '更新頻度'; + + @override + String get subscriptionAutomatic => '自動'; + + @override + String get subscriptionHourly => '毎時'; + + @override + String get subscriptionSixHours => '6 時間ごと'; + + @override + String get subscriptionDaily => '毎日'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'ソース: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + '安全なオリジンをプレビューするには有効な URL を入力してください。'; + + @override + String get subscriptionReadOnly => '読み取り専用の登録'; + + @override + String get subscriptionNeverRefreshed => 'まだ更新されていません'; + + @override + String subscriptionLastRefresh(String time) { + return '最後に成功した更新: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return '次回の更新: $time'; + } + + @override + String get subscriptionStatusHealthy => '最新'; + + @override + String subscriptionStatusIssue(String code) { + return '更新の問題: $code'; + } + + @override + String get refreshNow => '今すぐ更新'; + + @override + String get unsubscribe => '登録を解除'; + + @override + String unsubscribeCalendarTitle(String name) { + return '「$name」の登録を解除しますか?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'ローカル登録とキャッシュされた予定を削除します。公開されているカレンダーは変更されません。'; + + @override + String get addSubscriptionAction => '登録を追加'; + + @override + String subscriptionOperationFailed(String error) { + return 'カレンダー登録に失敗: $error'; + } + + @override + String get subscriptions => '登録'; + + @override + String get calendarImport => 'カレンダーをインポート'; + + @override + String get calendarImportDescription => + 'ファイルを選択して予定を確認し、受け入れ先の書き込み可能なカレンダーを選択します。'; + + @override + String get importIcsFile => '.ics ファイルをインポート'; + + @override + String get importIcsPreview => 'カレンダーの予定をインポート'; + + @override + String importEventsFound(int count) { + return 'インポート可能な予定セット: $count'; + } + + @override + String importInvalidEvents(int count) { + return '無効な予定: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return '意図的に省略: $fields'; + } + + @override + String get noWritableCalendars => '書き込み可能な移行先カレンダーがありません。'; + + @override + String get importDestinationCalendar => '移行先カレンダー'; + + @override + String get importIcsConfirm => '予定をインポート'; + + @override + String get importIcsComplete => 'インポート完了'; + + @override + String importQueued(int count) { + return 'インポート済みまたはキュー済み: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return '重複をスキップ: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'サポートされない繰り返しセット: $count'; + } + + @override + String importIcsFailed(String error) { + return 'カレンダーファイルをインポートできませんでした: $error'; + } + @override String get networkOffline => 'オフライン'; @@ -1906,4 +2466,139 @@ class AppLocalizationsJa extends AppLocalizations { @override String get networkOfflineTryAgain => 'オフラインです。インターネットに接続して、もう一度お試しください。'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return '$days'; + } + + @override + String get repeatSummarySeparator => ''; + + @override + String repeatMonthDayValue(String day) { + return '$day日'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first、$second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first、$second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$firstと$second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$firstと$second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency$month$day'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency$monthの$days'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency$monthsの$day'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency$monthsの$days'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第1$days', + 'second': '第2$days', + 'third': '第3$days', + 'fourth': '第4$days', + 'fifth': '第5$days', + 'secondToLast': '最後から2番目の$days', + 'last': '最後の$days', + 'other': '$days', + }); + return '$frequency$monthの$_temp0'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第1$days', + 'second': '第2$days', + 'third': '第3$days', + 'fourth': '第4$days', + 'fifth': '第5$days', + 'secondToLast': '最後から2番目の$days', + 'last': '最後の$days', + 'other': '$days', + }); + return '$frequency$monthsの$_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 1be1eed7..ec3b9424 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -12,12 +12,109 @@ import 'app_localizations.dart'; class AppLocalizationsKo extends AppLocalizations { AppLocalizationsKo([String locale = 'ko']) : super(locale); + @override + String get windowsSupport => '지원'; + + @override + String get windowsThirdPartyLicenses => '타사 라이선스'; + + @override + String get windowsSearch => '검색'; + + @override + String get windowsStartupDisabledByUser => + 'Windows 설정에서 사용자가 사용하지 않도록 설정했습니다.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Windows 정책에 의해 사용하지 않도록 설정되었습니다.'; + + @override + String get windowsStartupUnavailable => 'BusyMax를 MSIX 패키지로 설치하면 사용할 수 있습니다.'; + + @override + String get windowsReminderExitNotice => + 'BusyMax를 완전히 종료하면 미리 알림이 중지됩니다. 받으려면 백그라운드에서 계속 실행하세요.'; + + @override + String get windowsProductVersionLabel => '제품 버전'; + + @override + String get windowsPackageVersionLabel => 'Windows 패키지 버전'; + + @override + String get windowsUnpackaged => '패키지되지 않음'; + + @override + String get windowsAgendaLoadMore => '일정 항목 더 불러오기'; + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '월요일', + 'TU': '화요일', + 'WE': '수요일', + 'TH': '목요일', + 'FR': '금요일', + 'SA': '토요일', + 'SU': '일요일', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return '$first과 $second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency $month의 $firstDay과 $secondDay'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency $firstMonth과 $secondMonth의 $day'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency $firstMonth과 $secondMonth의 $firstDay과 $secondDay'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency $firstMonth과 $secondMonth의 $days'; + } + @override String get appTitle => 'BusyMax'; @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Google, Microsoft, Apple iCloud Calendar 또는 Nextcloud 계정을 연결하세요.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +138,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + '사용할 모든 계정을 추가하세요. BusyMax는 각 계정의 지원되는 캘린더, 일정, 할 일 목록 및 할 일을 동기화합니다.'; @override String get onboardingPreferencesStepTitle => '시스템 설정 선택'; @@ -117,6 +214,43 @@ class AppLocalizationsKo extends AppLocalizations { @override String get calendars => '캘린더'; + @override + String get newCalendar => '새 캘린더'; + + @override + String get calendarColor => '캘린더 색상'; + + @override + String calendarColorOption(int number) { + return '색상 $number'; + } + + @override + String get calendarManagementUnsupported => + '이 공급자는 BusyMax에서 캘린더 관리를 지원하지 않습니다.'; + + @override + String get primaryCalendarCannotDelete => '기본 캘린더는 삭제할 수 없습니다.'; + + @override + String calendarCreateFailed(String error) { + return '캘린더를 만들 수 없습니다: $error'; + } + + @override + String get calendarCreatedRefreshPending => + '캘린더가 생성되었지만 BusyMax에서 계정을 새로 고치지 못했습니다. 다음 동기화 후에 표시됩니다.'; + + @override + String calendarUpdateFailed(String error) { + return '캘린더를 업데이트할 수 없습니다: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return '캘린더를 삭제할 수 없습니다: $error'; + } + @override String get newEvent => '새 일정'; @@ -140,7 +274,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String moreItems(int count) { - return '+$count개 더 보기'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString개 더 보기'; } @override @@ -176,6 +314,101 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trayOpenBusyMax => 'BusyMax 열기'; + @override + String get trayShowBusyMax => 'BusyMax 표시'; + + @override + String get trayNewEvent => '새 일정…'; + + @override + String get trayNewTask => '새 할 일…'; + + @override + String get trayToday => '오늘'; + + @override + String get trayAllDay => '종일'; + + @override + String get trayNow => '지금'; + + @override + String get trayCalendarEvent => '캘린더 일정'; + + @override + String get trayUntitledEvent => '제목 없는 일정'; + + @override + String get trayNothingElseToday => '오늘은 더 이상 없음'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '오늘 마감인 할 일 $count개', + one: '오늘 마감인 할 일 1개', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => '오늘 일정 열기'; + + @override + String get traySyncNow => '지금 동기화'; + + @override + String get traySyncing => '동기화 중…'; + + @override + String get trayNotConnected => '연결되지 않음'; + + @override + String get trayNotYetSynced => '아직 동기화되지 않음'; + + @override + String get trayLastSyncedJustNow => '방금 동기화됨'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count분 전에 동기화됨', + one: '1분 전에 동기화됨', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count시간 전에 동기화됨', + one: '1시간 전에 동기화됨', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count일 전에 동기화됨', + one: '1일 전에 동기화됨', + ); + return '$_temp0'; + } + + @override + String get traySettings => '설정'; + + @override + String get trayQuitBusyMax => 'BusyMax 종료'; + @override String get agendaLoadMoreOverdue => '기한이 지난 할 일 더 불러오기'; @@ -259,127 +492,123 @@ class AppLocalizationsKo extends AppLocalizations { String get noGuests => '참석자 없음'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => '필수'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => '선택사항'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => '회의'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Google Meet 추가'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Microsoft Teams 회의 추가'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => '온라인 회의가 추가됨'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => '응답 요청'; @override - String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + String get requestResponsesDescription => '참석자에게 초대에 응답하도록 요청합니다.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => '참석자 목록 숨기기'; @override - String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + String get hideGuestListDescription => '참석자는 다른 초대 대상자를 볼 수 없습니다.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => '새 시간 제안 허용'; @override - String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + String get allowNewTimeProposalsDescription => '참석자가 다른 회의 시간을 제안할 수 있습니다.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => '참석자에게 알릴까요?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + '이 회의에는 참석자가 있습니다. 저장할 때 초대 또는 일정 업데이트를 보낼까요?'; @override - String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + String get notifyGuestsDeleteMessage => '이 회의에는 참석자가 있습니다. 삭제할 때 취소를 보낼까요?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => '업데이트 보내기'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => '취소 보내기'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => '보내지 않음'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => '회의를 저장할까요?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft에서 참석자에게 초대 또는 일정 업데이트를 보냅니다.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => '회의를 삭제할까요?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft에서 참석자에게 취소를 보냅니다.'; @override - String get organizer => 'Organizer'; + String get organizer => '주최자'; @override - String get yourResponse => 'Your response'; + String get yourResponse => '내 응답'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => '참석자 응답'; @override - String get respond => 'Respond'; + String get respond => '응답'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => '수락'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => '미정'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => '거절'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => '회의 참가'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => '수락됨'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => '미정'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => '거절됨'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => '응답 대기 중'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => '응답하지 않음'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => '주최자'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return '응답을 보낼 수 없습니다: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => '회의 링크를 열 수 없습니다.'; @override String get description => '설명'; @@ -425,10 +654,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes분 전', + other: '$minutesString분 전', one: '1분 전', ); return '$_temp0'; @@ -439,10 +672,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours시간 전', + other: '$hoursString시간 전', one: '1시간 전', ); return '$_temp0'; @@ -450,10 +687,15 @@ class AppLocalizationsKo extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days일 전', + other: '$daysString일 전', one: '1일 전', ); return '$_temp0'; @@ -636,7 +878,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Linux 운영 체제 버전과 앱 로캘만 추가됩니다. 로그, 계정 데이터, 파일 이름 또는 기타 진단 정보는 포함되지 않습니다.'; + '운영 체제 이름과 버전 및 앱 로캘만 추가됩니다. 로그, 계정 데이터, 파일 이름 또는 기타 진단 정보는 포함되지 않습니다.'; @override String get feedbackCategoryRequired => '범주를 선택하세요.'; @@ -718,12 +960,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String removeAccountTitle(String account) { - return 'BusyMax에서 $account 계정을 삭제할까요?'; + return '$account을(를) BusyMax에서 삭제할까요?'; } @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + '이 작업은 이 기기에서 캐시된 할 일, 캘린더, 일정, 알림 및 보류 중인 오프라인 변경사항을 삭제합니다. 동기화되지 않은 변경사항은 손실됩니다. 제공업체의 캘린더, 일정, 할 일 목록 및 할 일 사본은 삭제되지 않습니다.'; @override String get revokeGoogleAccess => '이 Google 계정에 대한 BusyMax의 액세스 권한도 취소'; @@ -746,17 +988,17 @@ class AppLocalizationsKo extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return '할 일 목록을 만들 수 없습니다: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return '할 일 목록 이름을 바꿀 수 없습니다: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return '할 일 목록을 삭제할 수 없습니다: $error'; } @override @@ -785,11 +1027,10 @@ class AppLocalizationsKo extends AppLocalizations { @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + '이 할 일 목록은 읽기 전용이므로 이름을 바꿀 수 없습니다.'; @override - String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + String get taskListCannotDelete => '현재 권한으로는 이 할 일 목록을 삭제할 수 없습니다.'; @override String get builtInMicrosoftList => '기본 제공'; @@ -800,17 +1041,17 @@ class AppLocalizationsKo extends AppLocalizations { @override String deleteListConfirmation(String title) { - return 'Google Tasks에서 “$title” 목록을 삭제할까요?'; + return 'Google Tasks에서 “$title”을(를) 삭제할까요?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return '“$title” 및 모든 할 일을 삭제할까요?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return '이 계정에서 “$title” 공유를 해제할까요?'; } @override @@ -935,72 +1176,88 @@ class AppLocalizationsKo extends AppLocalizations { String get doneStatus => '완료'; @override - String get taskStatus => 'Status'; + String get taskStatus => '상태'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => '상태 없음'; @override - String get taskStatusNeedsAction => 'Needs action'; + String get taskStatusNeedsAction => '조치 필요'; @override - String get taskStatusInProcess => 'In process'; + String get taskStatusInProcess => '진행 중'; @override - String get taskStatusCompleted => '완료됨'; + String get taskStatusCompleted => '완료'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => '취소됨'; @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% 완료'; } @override - String get completionDate => 'Completion date'; + String get completionDate => '완료 날짜'; @override - String get priority => '우선 순위'; + String get priority => '우선순위'; @override - String get priorityNone => 'No priority'; + String get priorityNone => '우선순위 없음'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '우선순위 $priorityString · 높음'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '우선순위 $priorityString · 중간'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '우선순위 $priorityString · 낮음'; } @override - String get taskUrl => 'URL'; + String get taskUrl => '할 일 URL'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => '스킴을 포함한 절대 URL을 입력하세요.'; @override - String get classification => 'Classification'; + String get classification => '분류'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => '공유할 때 전체 할 일 표시'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => '공유할 때 바쁨 상태만 표시'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => '공유할 때 이 할 일 숨기기'; @override - String get pinTask => 'Pin task'; + String get pinTask => '할 일 고정'; @override String get notes => '메모'; @@ -1036,85 +1293,88 @@ class AppLocalizationsKo extends AppLocalizations { String get reminder => '미리 알림'; @override - String get addReminder => '미리 알림 추가'; + String get addReminder => '알림 추가'; @override - String get reminders => 'Reminders'; + String get reminders => '알림'; @override - String get noReminders => 'No reminders'; + String get noReminders => '알림 없음'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => '알림 수정'; @override - String get beforeTaskStarts => 'Before the task starts'; + String get beforeTaskStarts => '할 일 시작 전'; @override - String get beforeTaskDue => 'Before the task is due'; + String get beforeTaskDue => '할 일 마감 전'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => '할 일 시작 후'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => '할 일 마감 후'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => '할 일 시작일 기준'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => '할 일 마감일 기준'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => '시간'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => '날짜 및 시간에'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => '수량'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => '단위'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => '초'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => '분'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => '시간'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => '일'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => '주'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => '할 일 시작 시'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => '할 일 마감 시'; @override - String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + String get unsupportedReminder => '이 알림 유형은 유지되지만 시간을 수정할 수 없습니다.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => '관련 알림을 유지할까요?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '이 날짜에 관련 알림이 $countString개 있습니다. 현재 날짜와 시간에 유지할까요?'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => '알림 삭제'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => '알림 유지'; @override String get addGuest => '참석자 추가'; @@ -1150,121 +1410,179 @@ class AppLocalizationsKo extends AppLocalizations { String get repeatYearly => '매년'; @override - String get repeatEvery => '반복 주기'; + String get repeatEvery => '반복 간격'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => '반복 요일'; @override String get repeatEnd => '반복 종료'; @override - String get repeatNever => 'Never'; + String get repeatNever => '안 함'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => '날짜까지'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => '지정한 횟수 후'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => '반복 횟수'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => '월의 날짜'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => '월'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => '요일 순서'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => '특정 요일'; @override - String get repeatFirst => 'First'; + String get repeatFirst => '첫 번째'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => '두 번째'; @override - String get repeatThird => 'Third'; + String get repeatThird => '세 번째'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => '네 번째'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => '다섯 번째'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => '끝에서 두 번째'; @override - String get repeatLast => 'Last'; + String get repeatLast => '마지막'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => '요일'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => '평일'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => '주말'; + + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '월요일', + 'TU': '화요일', + 'WE': '수요일', + 'TH': '목요일', + 'FR': '금요일', + 'SA': '토요일', + 'SU': '일요일', + 'day': '날', + 'weekday': '평일', + 'weekend': '주말', + 'other': '$day', + }); + return '$_temp0'; + } @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '$countString일마다'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '$countString주마다'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '$countString개월마다'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '$countString년마다'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return '$days에'; } @override String repeatOnMonthDaysSummary(String days) { - return 'on day $days'; + return '$days'; } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '첫 번째 $days', + 'second': '두 번째 $days', + 'third': '세 번째 $days', + 'fourth': '네 번째 $days', + 'fifth': '다섯 번째 $days', + 'secondToLast': '끝에서 두 번째 $days', + 'last': '마지막 $days', + 'other': '$days', + }); + return '$_temp0'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return '$months에'; } @override String repeatTimesSummary(int count) { - return '$count회'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString회', + ); + return '$_temp0'; } @override String repeatUntilSummary(String date) { - return 'until $date'; + return '$date까지'; } @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + '이 반복 규칙에는 이 편집기에서 변경하지 않는 옵션이 사용됩니다.'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return '이 반복은 $provider에서 사용할 수 없습니다.'; + } @override String get importance => '중요도'; @@ -1319,21 +1637,21 @@ class AppLocalizationsKo extends AppLocalizations { String get subtasks => '하위 할 일'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => '할 일 복제'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => '할 일이 복제되었습니다.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return '할 일을 복제할 수 없습니다: $error'; } @override - String get hideSubtasks => 'Hide subtasks'; + String get hideSubtasks => '하위 할 일 숨기기'; @override - String get hideClosedSubtasks => 'Hide closed subtasks'; + String get hideClosedSubtasks => '완료된 하위 할 일 숨기기'; @override String get moveToTop => '맨 위로 이동'; @@ -1389,7 +1707,11 @@ class AppLocalizationsKo extends AppLocalizations { String get sync => '동기화'; @override - String get manualFullSync => '수동 전체 동기화'; + String get forceFullResync => '전체 재동기화 강제 실행'; + + @override + String get forceFullResyncDescription => + '연결된 모든 계정의 데이터를 완전히 다시 불러옵니다. 동기화 문제를 해결할 때만 사용하세요.'; @override String get runInBackgroundWhenClosed => '창을 닫아도 계속 실행'; @@ -1433,6 +1755,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get eventReminders => '일정 미리 알림'; + @override + String get onState => '켬'; + @override String get taskReminders => '할 일 미리 알림'; @@ -1460,6 +1785,17 @@ class AppLocalizationsKo extends AppLocalizations { @override String get notifications => '알림'; + @override + String get windowsNotificationsUnavailable => 'Windows 알림을 사용할 수 없음'; + + @override + String get windowsNotificationsUnpackaged => + '패키징되지 않은 이 개발 실행에서는 Windows 알림을 사용할 수 없습니다. 미리 알림을 테스트하려면 테스트 서명된 MSIX를 설치하세요.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax에서 Windows 알림을 초기화하지 못했습니다. 이 설치 문제가 해결될 때까지 미리 알림이 표시되지 않습니다.'; + @override String get appearance => '화면 모양'; @@ -1469,6 +1805,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get themeSystem => '시스템'; + @override + String get settingsSystem => '시스템'; + @override String get themeLight => '라이트'; @@ -1543,7 +1882,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String pendingOpAttempts(int count) { - return '시도=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '시도=$countString'; } @override @@ -1587,7 +1930,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return '보류 중인 로컬 변경 사항이 차단되었습니다. $summary'; + return '보류 중인 로컬 변경사항이 차단되었습니다. $summary'; } @override @@ -1595,10 +1938,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '오늘 마감인 할 일이 $count개 있습니다.', + other: '오늘 마감인 할 일이 $countString개 있습니다.', one: '오늘 마감인 할 일이 1개 있습니다.', ); return '$_temp0'; @@ -1623,7 +1970,7 @@ class AppLocalizationsKo extends AppLocalizations { String get notificationSnoozeAction => '10분 후 다시 알림'; @override - String get notificationDismissAction => '해제'; + String get notificationDismissAction => '닫기'; @override String get notificationDetailsHidden => '개인정보 보호 설정에 따라 세부 정보가 숨겨졌습니다.'; @@ -1648,7 +1995,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String weekNumberTooltip(int number) { - return '$number주차'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return '$numberString주차'; } @override @@ -1656,10 +2007,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '항목 $count개', + other: '항목 $countString개', one: '항목 1개', ); return '$_temp0'; @@ -1675,17 +2030,16 @@ class AppLocalizationsKo extends AppLocalizations { String get searchLocations => '위치 검색'; @override - String get noLocationsFound => '위치를 찾을 수 없습니다'; + String get noLocationsFound => '위치를 찾을 수 없음'; @override - String get requiredField => 'This field is required.'; + String get requiredField => '이 필드는 필수입니다.'; @override - String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + String get providerConnectionDescription => '다음 제공업체 중 하나에서 캘린더와 할 일을 연결하세요.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Apple iCloud 캘린더'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1694,198 +2048,215 @@ class AppLocalizationsKo extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Nextcloud 할 일'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'Apple iCloud 캘린더 계정 추가'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Nextcloud 계정 추가'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Apple iCloud에 연결 중…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Nextcloud 인증 대기 중…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Apple iCloud 캘린더 연결'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Apple 계정 이메일'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => '앱 전용 암호'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Apple 계정에서 이중 인증을 활성화한 후 앱 전용 암호를 생성하세요.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Apple 계정 암호를 재설정하면 앱 전용 암호가 취소됩니다.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Nextcloud 연결'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Nextcloud 서버 또는 CalDAV 주소'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Nextcloud 서버 URL을 입력하거나 Nextcloud에서 복사한 기본 CalDAV 주소를 붙여넣으세요.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax가 브라우저를 엽니다. 그곳에서 액세스를 승인한 다음 BusyMax로 돌아오세요.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => '연결'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => '연결 취소'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + '계정은 로컬에서 삭제되었지만 Nextcloud 앱 암호를 취소할 수 없습니다.'; @override - String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + String get davCachedOfflineNotice => '오프라인 사용을 위해 캘린더 및 할 일 데이터가 로컬에 캐시됩니다.'; @override - String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + String get davReauthenticationRequired => '동기화를 재개하려면 이 계정을 다시 연결하세요.'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get davTemporarilyUnavailable => '이 계정은 일시적으로 사용할 수 없습니다.'; @override - String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + String get davPermissionChanged => '서버 권한이 변경되었습니다. 보류 중인 편집이 일시 중지되었습니다.'; @override - String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + String get davUnsupportedServer => '이 서버 또는 제공업체 프로필은 지원되지 않습니다.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => '캘린더 및 할 일 목록'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => '캘린더 일정'; @override - String get taskContent => 'Tasks'; + String get taskContent => '할 일'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => '읽기 전용'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => '로컬에서 보류 중'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => '충돌로 차단됨'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => '다시 연결할 때까지 차단됨'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => '작업 실패'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => '서버 버전 유지'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => '로컬 변경사항 검토 후 다시 적용'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => '새 항목으로 복제'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => '연결 상태'; @override - String get davConnected => 'Connected'; + String get davConnected => '연결됨'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => '연결 중…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => '로그아웃됨'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return '마지막으로 성공한 동기화: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => '아직 동기화되지 않음'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => '캘린더 및 할 일 목록 새로 고침'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return '서버: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => '일정 캘린더'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => '할 일 목록'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => '일정 및 할 일'; @override - String get writableCollection => 'Writable'; + String get writableCollection => '쓰기 가능'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => '공유됨'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return '마지막 동기화: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return '동기화 문제: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => '동기화 충돌'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return '서버 변경: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return '로컬 편집: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => '충돌을 해결할 수 없습니다.'; + + @override + String get recurringEventScope => '반복 일정 범위'; + + @override + String get entireSeries => '전체 시리즈'; + + @override + String get singleOccurrence => '이 일정'; @override - String get recurringEventScope => 'Recurring event scope'; + String get thisAndFollowingEvents => '이 일정 및 향후 일정'; @override - String get entireSeries => 'Entire series'; + String get thisAndFutureUnavailable => '이 제공업체에서는 지원되지 않습니다.'; @override - String get singleOccurrence => 'This occurrence'; + String get thisAndFutureMoveUnavailable => + '이 일정과 이후 일정을 안전하게 이동할 수 없습니다. 이 일정 또는 전체 반복 일정을 선택하세요.'; @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get entireSeriesMoveUnavailable => + '반복 규칙을 로컬에서 사용할 수 없습니다. 이 일정만 이동하세요.'; + + @override + String get copyEventAndDeleteOriginal => '일정을 복사하고 원본을 삭제할까요?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax는 이 일정을 $source에서 $destination(으)로 직접 이동할 수 없습니다. 먼저 복사본을 만들고 복사가 성공한 뒤에만 원본을 삭제합니다. 일정 ID가 변경되고 참석자 응답 상태가 초기화되며 초대 또는 취소가 전송될 수 있습니다. 회의 링크, 첨부 파일, 미리 알림, 제공업체별 필드 및 반복 예외는 이전되지 않을 수 있습니다.'; + } + + @override + String get copyAndDelete => '복사 후 삭제'; @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + '이 변경을 전체 시리즈, 이 일정만 또는 이 일정과 이후 일정에 적용할지 선택하세요.'; @override String get taskDueBeforeStart => '마감은 시작보다 빠를 수 없습니다.'; @@ -1899,6 +2270,197 @@ class AppLocalizationsKo extends AppLocalizations { return '“$title” 캘린더를 삭제할까요?'; } + @override + String get setCustomCalendarName => '맞춤 이름 설정'; + + @override + String get setAction => '설정'; + + @override + String get removeFromMyCalendars => '내 캘린더에서 삭제'; + + @override + String get removeAction => '삭제'; + + @override + String removeCalendarConfirmation(String title) { + return 'Google Calendar 목록에서 \"$title\"을(를) 삭제할까요? 공유 캘린더와 일정은 삭제되지 않습니다.'; + } + + @override + String get calendarCannotRemove => '이 캘린더는 이 계정에서 삭제하거나 제거할 수 없습니다.'; + + @override + String get calendarPendingChangesPreventRemoval => + '삭제하거나 제거하기 전에 이 캘린더의 보류 중인 변경사항이 동기화될 때까지 기다리세요.'; + + @override + String get calendarSubscriptions => '캘린더 구독'; + + @override + String get calendarSubscriptionsDescription => + '보안 WebCal URL에서 새로 고침되는 읽기 전용 캘린더를 추가하세요.'; + + @override + String get addCalendarSubscription => '캘린더 구독 추가'; + + @override + String get subscriptionName => '로컬 이름'; + + @override + String get subscriptionUrl => '구독 URL'; + + @override + String get subscriptionUrlHelp => + 'HTTPS 또는 webcal URL을 입력하세요. BusyMax는 전체 URL을 안전한 저장소에 보관합니다.'; + + @override + String get subscriptionUrlInvalid => + '사용자 정보나 프래그먼트가 없는 올바른 HTTPS 또는 webcal URL을 입력하세요.'; + + @override + String get subscriptionColor => '로컬 색상'; + + @override + String get subscriptionColorHelp => '#3584E4와 같은 6자리 색상을 사용하세요.'; + + @override + String get subscriptionColorInvalid => '6자리 16진수 색상을 입력하세요.'; + + @override + String get subscriptionRefreshMode => '새로 고침 빈도'; + + @override + String get subscriptionAutomatic => '자동'; + + @override + String get subscriptionHourly => '매시간'; + + @override + String get subscriptionSixHours => '6시간마다'; + + @override + String get subscriptionDaily => '매일'; + + @override + String subscriptionSafeOrigin(String origin) { + return '소스: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + '안전한 원본을 미리 보려면 올바른 URL을 입력하세요.'; + + @override + String get subscriptionReadOnly => '읽기 전용 구독'; + + @override + String get subscriptionNeverRefreshed => '아직 새로 고치지 않음'; + + @override + String subscriptionLastRefresh(String time) { + return '마지막으로 성공한 새로 고침: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return '다음 새로 고침: $time'; + } + + @override + String get subscriptionStatusHealthy => '최신 상태'; + + @override + String subscriptionStatusIssue(String code) { + return '새로 고침 문제: $code'; + } + + @override + String get refreshNow => '지금 새로 고침'; + + @override + String get unsubscribe => '구독 취소'; + + @override + String unsubscribeCalendarTitle(String name) { + return '“$name” 구독을 취소할까요?'; + } + + @override + String get unsubscribeCalendarConfirmation => + '로컬 구독과 캐시된 일정을 제거합니다. 게시된 캘린더는 변경되지 않습니다.'; + + @override + String get addSubscriptionAction => '구독 추가'; + + @override + String subscriptionOperationFailed(String error) { + return '캘린더 구독 실패: $error'; + } + + @override + String get subscriptions => '구독'; + + @override + String get calendarImport => '캘린더 가져오기'; + + @override + String get calendarImportDescription => + '파일을 선택하고 일정을 검토한 다음 이를 받을 쓰기 가능한 캘린더를 선택하세요.'; + + @override + String get importIcsFile => '.ics 파일 가져오기'; + + @override + String get importIcsPreview => '캘린더 일정 가져오기'; + + @override + String importEventsFound(int count) { + return '가져올 수 있는 일정 세트: $count'; + } + + @override + String importInvalidEvents(int count) { + return '잘못된 일정: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return '의도적으로 생략됨: $fields'; + } + + @override + String get noWritableCalendars => '쓰기 가능한 대상 캘린더가 없습니다.'; + + @override + String get importDestinationCalendar => '대상 캘린더'; + + @override + String get importIcsConfirm => '일정 가져오기'; + + @override + String get importIcsComplete => '가져오기 완료'; + + @override + String importQueued(int count) { + return '가져왔거나 대기열에 추가됨: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return '중복 건너뜀: $count'; + } + + @override + String importUnsupportedSets(int count) { + return '지원되지 않는 반복 세트: $count'; + } + + @override + String importIcsFailed(String error) { + return '캘린더 파일을 가져올 수 없습니다: $error'; + } + @override String get networkOffline => '오프라인'; @@ -1907,4 +2469,139 @@ class AppLocalizationsKo extends AppLocalizations { @override String get networkOfflineTryAgain => '오프라인 상태입니다. 인터넷에 연결한 후 다시 시도하세요.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return '$days'; + } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(String day) { + return '$day일'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first과 $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first과 $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first과 $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first과 $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency $month $day'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency $month의 $days'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency $months의 $day'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency $months의 $days'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '첫 번째 $days', + 'second': '두 번째 $days', + 'third': '세 번째 $days', + 'fourth': '네 번째 $days', + 'fifth': '다섯 번째 $days', + 'secondToLast': '끝에서 두 번째 $days', + 'last': '마지막 $days', + 'other': '$days', + }); + return '$frequency $month의 $_temp0'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '첫 번째 $days', + 'second': '두 번째 $days', + 'third': '세 번째 $days', + 'fourth': '네 번째 $days', + 'fifth': '다섯 번째 $days', + 'secondToLast': '끝에서 두 번째 $days', + 'last': '마지막 $days', + 'other': '$days', + }); + return '$frequency $months의 $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index c7063501..f304dbde 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -12,12 +12,2786 @@ import 'app_localizations.dart'; class AppLocalizationsPt extends AppLocalizations { AppLocalizationsPt([String locale = 'pt']) : super(locale); + @override + String get windowsSupport => 'Support'; + + @override + String get windowsThirdPartyLicenses => 'Third-party licenses'; + + @override + String get windowsSearch => 'Search'; + + @override + String get windowsStartupDisabledByUser => + 'Disabled by the user in Windows Settings.'; + + @override + String get windowsStartupDisabledByPolicy => 'Disabled by Windows policy.'; + + @override + String get windowsStartupUnavailable => + 'Available after BusyMax is installed from an MSIX package.'; + + @override + String get windowsReminderExitNotice => + 'Reminders stop when BusyMax is fully quit. Keep it running in the background to receive them.'; + + @override + String get windowsProductVersionLabel => 'Product version'; + + @override + String get windowsPackageVersionLabel => 'Windows package version'; + + @override + String get windowsUnpackaged => 'Unpackaged'; + + @override + String get windowsAgendaLoadMore => 'Load more agenda items'; + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'Monday', + 'TU': 'Tuesday', + 'WE': 'Wednesday', + 'TH': 'Thursday', + 'FR': 'Friday', + 'SA': 'Saturday', + 'SU': 'Sunday', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'on days $first and $second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency on days $firstDay and $secondDay of $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency on day $day of $firstMonth and $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency on days $firstDay and $secondDay of $firstMonth and $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency on days $days of $firstMonth and $secondMonth'; + } + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + + @override + String get googlePermissionsConsentNotice => + 'On the Google permission screen, select both Calendar and Tasks permissions.'; + + @override + String get googlePermissionsRequiredRetry => + 'Google Calendar and Google Tasks permissions are required. Please try again and select both checkboxes.'; + + @override + String get finishSetup => 'Finish setup'; + + @override + String get continueSetup => 'Continue'; + + @override + String get onboardingSetupTitle => 'Set Up BusyMax'; + + @override + String get onboardingAccountsStepTitle => 'Connect accounts'; + + @override + String get onboardingAccountsStepDescription => + 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + + @override + String get onboardingPreferencesStepTitle => 'Choose system settings'; + + @override + String get onboardingPreferencesStepDescription => + 'Set desktop behavior, reminders, notification detail, and appearance before opening your schedule.'; + + @override + String get signInWithGoogle => 'Sign in with Google'; + + @override + String get signInWithMicrosoft => 'Sign in with Microsoft'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'This provider is not configured.'; + + @override + String get waitingForGoogleSignIn => 'Waiting for Google sign-in...'; + + @override + String get waitingForMicrosoftSignIn => 'Waiting for Microsoft sign-in...'; + + @override + String get microsoftSignInNotConfigured => + 'Microsoft sign-in is not configured. Set MICROSOFT_OAUTH_CLIENT_ID.'; + + @override + String get cancel => 'Cancel'; + + @override + String get close => 'Close'; + + @override + String get exit => 'Exit'; + + @override + String get options => 'Options'; + + @override + String get hide => 'Hide'; + + @override + String get show => 'Show'; + + @override + String get export => 'Export'; + + @override + String get save => 'Save'; + + @override + String get settings => 'Settings'; + + @override + String get all => 'All'; + + @override + String get calendarEvents => 'Events'; + + @override + String get calendarTasks => 'Tasks'; + + @override + String get calendar => 'Calendar'; + + @override + String get calendars => 'Calendars'; + + @override + String get newCalendar => 'New calendar'; + + @override + String get calendarColor => 'Calendar color'; + + @override + String calendarColorOption(int number) { + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Color $numberString'; + } + + @override + String get calendarManagementUnsupported => + 'This provider does not support calendar management in BusyMax.'; + + @override + String get primaryCalendarCannotDelete => + 'The primary calendar cannot be deleted.'; + + @override + String calendarCreateFailed(String error) { + return 'Could not create the calendar: $error'; + } + + @override + String get calendarCreatedRefreshPending => + 'The calendar was created, but BusyMax could not refresh the account. It will appear after the next sync.'; + + @override + String calendarUpdateFailed(String error) { + return 'Could not update the calendar: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Could not delete the calendar: $error'; + } + + @override + String get newEvent => 'New event'; + + @override + String get refreshCalendar => 'Refresh calendar'; + + @override + String get openInProvider => 'Open in provider'; + + @override + String get hideFromSchedule => 'Hide from schedule'; + + @override + String get showInSchedule => 'Show in schedule'; + + @override + String get noCalendarsSynced => 'No calendars synced yet.'; + + @override + String get allDay => 'All day'; + + @override + String moreItems(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString more'; + } + + @override + String get noEventsOrTasks => 'No events or tasks'; + + @override + String get scheduleLoading => 'Loading schedule...'; + + @override + String get scheduleUnavailable => 'Schedule unavailable'; + + @override + String get scheduleNoSources => 'No visible calendars or task lists'; + + @override + String get scheduleNoSourcesDescription => + 'Choose what to show in Settings, then refresh.'; + + @override + String get scheduleSignInRequired => 'Connect an account'; + + @override + String get scheduleSignInDescription => + 'Sign in to sync calendars and tasks.'; + + @override + String get scheduleNoSearchResults => 'No matching events or tasks'; + + @override + String get scheduleNoSearchResultsDescription => + 'Try a different search or clear the current filters.'; + + @override + String get refresh => 'Refresh'; + + @override + String get trayOpenBusyMax => 'Open BusyMax'; + + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $countString minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $countString hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $countString days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + + @override + String get agendaLoadMoreOverdue => 'Load more overdue tasks'; + + @override + String get agendaLoadMoreNoDate => 'Load more no-date tasks'; + + @override + String get viewDay => 'Day'; + + @override + String get viewWeek => 'Week'; + + @override + String get viewMonth => 'Month'; + + @override + String get viewYear => 'Year'; + + @override + String get viewAgenda => 'Agenda'; + + @override + String get scheduleSettings => 'Schedule'; + + @override + String get scheduleDisplaySettings => 'Schedule display'; + + @override + String get scheduleDisplayHoursDescription => + 'Day and Week views open within these hours. Early and late items expand the range when needed.'; + + @override + String get scheduleDayStartsAt => 'Day starts at'; + + @override + String get scheduleDayEndsAt => 'Day ends at'; + + @override + String get sourceCalendar => 'Calendar'; + + @override + String get sourceTaskList => 'Task list'; + + @override + String get createChoiceTitle => 'Create'; + + @override + String get createEventAtTime => 'Event'; + + @override + String get createTaskAtDate => 'Task'; + + @override + String get editEvent => 'Edit event'; + + @override + String get eventTitle => 'Event title'; + + @override + String get location => 'Location'; + + @override + String get timeSlot => 'Time slot'; + + @override + String get startDateTime => 'Start date/time'; + + @override + String get endDateTime => 'End date/time'; + + @override + String get doesNotRepeat => 'Does not repeat'; + + @override + String get defaultReminder => 'Default reminder'; + + @override + String get guests => 'Guests'; + + @override + String get noGuests => 'No guests'; + + @override + String get attendeeRequired => 'Required'; + + @override + String get attendeeOptional => 'Optional'; + + @override + String get meetingSection => 'Meeting'; + + @override + String get addGoogleMeet => 'Add Google Meet'; + + @override + String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + + @override + String get onlineMeetingAdded => 'Online meeting added'; + + @override + String get requestResponses => 'Request responses'; + + @override + String get requestResponsesDescription => + 'Ask guests to respond to the invitation.'; + + @override + String get hideGuestList => 'Hide guest list'; + + @override + String get hideGuestListDescription => + 'Guests cannot see who else was invited.'; + + @override + String get allowNewTimeProposals => 'Allow new time proposals'; + + @override + String get allowNewTimeProposalsDescription => + 'Guests can suggest a different meeting time.'; + + @override + String get notifyGuestsTitle => 'Notify guests?'; + + @override + String get notifyGuestsSaveMessage => + 'This meeting has guests. Send invitations or event updates when it is saved?'; + + @override + String get notifyGuestsDeleteMessage => + 'This meeting has guests. Send a cancellation when it is deleted?'; + + @override + String get sendUpdates => 'Send updates'; + + @override + String get sendCancellation => 'Send cancellation'; + + @override + String get doNotSend => 'Don’t send'; + + @override + String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + + @override + String get microsoftNotifyGuestsSaveMessage => + 'Microsoft will send invitations or event updates to guests.'; + + @override + String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + + @override + String get microsoftNotifyGuestsDeleteMessage => + 'Microsoft will send a cancellation to guests.'; + + @override + String get organizer => 'Organizer'; + + @override + String get yourResponse => 'Your response'; + + @override + String get guestResponses => 'Guest responses'; + + @override + String get respond => 'Respond'; + + @override + String get acceptInvitation => 'Accept'; + + @override + String get tentativeInvitation => 'Tentative'; + + @override + String get declineInvitation => 'Decline'; + + @override + String get joinMeeting => 'Join meeting'; + + @override + String get responseAccepted => 'Accepted'; + + @override + String get responseTentative => 'Tentative'; + + @override + String get responseDeclined => 'Declined'; + + @override + String get responseNeedsAction => 'Awaiting response'; + + @override + String get responseNotResponded => 'Not responded'; + + @override + String get responseOrganizer => 'Organizer'; + + @override + String invitationResponseFailed(String error) { + return 'Could not send your response: $error'; + } + + @override + String get joinMeetingFailed => 'Could not open the meeting link.'; + + @override + String get description => 'Description'; + + @override + String get availabilityShowAs => 'Availability / Show as'; + + @override + String get busy => 'Busy'; + + @override + String get visibility => 'Visibility'; + + @override + String get defaultVisibility => 'Default visibility'; + + @override + String get conference => 'Conference'; + + @override + String get noConference => 'No conference'; + + @override + String get providerCalendar => 'Provider calendar'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => 'Bold'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => 'Italic'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => 'Underline'; + + @override + String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutesString minutes before', + one: '1 minute before', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'At start'; + + @override + String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hoursString hours before', + one: '1 hour before', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$daysString days before', + one: '1 day before', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Free'; + + @override + String get availabilityTentative => 'Tentative'; + + @override + String get availabilityOutOfOffice => 'Out of office'; + + @override + String get availabilityWorkingElsewhere => 'Working elsewhere'; + + @override + String get visibilityDefault => 'Default'; + + @override + String get visibilityPublic => 'Public'; + + @override + String get visibilityPrivate => 'Private'; + + @override + String get visibilityConfidential => 'Confidential'; + + @override + String get sensitivityNormal => 'Normal'; + + @override + String get sensitivityPersonal => 'Personal'; + + @override + String get tasks => 'Tasks'; + + @override + String get allTasks => 'All tasks'; + + @override + String tasksInList(String title) { + return 'Tasks in $title'; + } + + @override + String get taskLists => 'Task lists'; + + @override + String get navigation => 'Navigation'; + + @override + String get mainMenu => 'Main Menu'; + + @override + String get keyboardShortcuts => 'Keyboard Shortcuts'; + + @override + String get shortcutGroupGeneral => 'General'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'Show this shortcuts reference'; + + @override + String get shortcutGroupNavigation => 'Navigation'; + + @override + String get shortcutNextPeriod => 'Next period'; + + @override + String get shortcutNextPeriodDescription => + 'Next week in week view, next month in month view, and so on'; + + @override + String get shortcutPreviousPeriod => 'Previous period'; + + @override + String get shortcutPreviousPeriodDescription => + 'Previous week in week view, previous month in month view, and so on'; + + @override + String get shortcutJumpToToday => 'Jump to today'; + + @override + String get shortcutGroupView => 'View'; + + @override + String get shortcutDayView => 'Day view'; + + @override + String get shortcutWeekView => 'Week view'; + + @override + String get shortcutMonthView => 'Month view'; + + @override + String get shortcutYearView => 'Year view'; + + @override + String get shortcutAgendaView => 'Agenda view'; + + @override + String get shortcutGroupCreateAndEdit => 'Create and Edit'; + + @override + String get shortcutSaveItem => 'Save event or task'; + + @override + String get shortcutDeleteItem => 'Delete event or task'; + + @override + String get shortcutGroupTaskEditing => 'Task editing'; + + @override + String get shortcutCancelEditing => 'Cancel editing'; + + @override + String get shortcutCancelEditingDescription => + 'Close task editing or task details'; + + @override + String get aboutBusyMax => 'About BusyMax'; + + @override + String get aboutBusyMaxDescription => 'Calendar and tasks'; + + @override + String get license => 'License'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + + @override + String get website => 'Website'; + + @override + String get sourceCode => 'Source code'; + + @override + String get reportAnIssue => 'Report an issue'; + + @override + String get sendFeedback => 'Send feedback'; + + @override + String get feedbackSubmit => 'Submit'; + + @override + String get feedbackCategory => 'Category'; + + @override + String get feedbackSelectCategory => 'Select a category'; + + @override + String get feedbackCategoryProblem => 'Problem or bug'; + + @override + String get feedbackCategoryFeature => 'Feature request'; + + @override + String get feedbackCategoryPrivacySecurity => 'Privacy or security concern'; + + @override + String get feedbackCategoryUsability => 'Usability concern'; + + @override + String get feedbackCategoryOther => 'Other'; + + @override + String get feedbackSubject => 'Subject'; + + @override + String get feedbackDetailedMessage => 'Detailed message'; + + @override + String get feedbackReplyEmail => 'Reply email (optional)'; + + @override + String get feedbackIncludeTechnicalDetails => 'Include technical details'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Adds only your operating-system name and version and application locale. No logs, account data, file names, or other diagnostics are included.'; + + @override + String get feedbackCategoryRequired => 'Select a category.'; + + @override + String get feedbackSubjectLengthError => + 'Subject must be between 3 and 120 characters.'; + + @override + String get feedbackMessageLengthError => + 'Message must be between 10 and 5,000 characters.'; + + @override + String get feedbackInvalidEmail => 'Enter a valid email address.'; + + @override + String get feedbackConnectionError => + 'Could not connect to BusyStack. Check your connection and try again.'; + + @override + String get feedbackTimeoutError => + 'The request timed out. Your feedback has not been cleared; please try again.'; + + @override + String get feedbackRateLimitedError => + 'Too many feedback submissions have been sent from this network. Please wait and try again.'; + + @override + String get feedbackRejectedError => + 'The server rejected the submission. Review the fields and try again.'; + + @override + String get feedbackServerError => + 'BusyStack could not accept your feedback right now. Your feedback has not been cleared; please try again.'; + + @override + String feedbackSuccess(String id) { + return 'Feedback sent. Reference: $id'; + } + + @override + String get toggleSidebar => 'Toggle Sidebar'; + + @override + String get showSidebar => 'Show sidebar panel'; + + @override + String get hideSidebar => 'Hide sidebar panel'; + + @override + String get accounts => 'Accounts'; + + @override + String get currentAccount => 'Current account'; + + @override + String get switchAccount => 'Switch account'; + + @override + String get addGoogleAccount => 'Add Google account'; + + @override + String get addMicrosoftAccount => 'Add Microsoft account'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'Signed in'; + + @override + String get removeAccount => 'Remove account…'; + + @override + String get removingAccount => 'Removing account…'; + + @override + String get removeAccountDescription => + 'Stop syncing and remove this account’s data from this device.'; + + @override + String removeAccountTitle(String account) { + return 'Remove $account from BusyMax?'; + } + + @override + String get removeAccountConfirmation => + 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + + @override + String get revokeGoogleAccess => + 'Also revoke BusyMax’s access to this Google Account'; + + @override + String get revokeGoogleAccessDescription => + 'You will need to grant access again before reconnecting.'; + + @override + String get removeAccountAction => 'Remove account'; + + @override + String get removeAccountFailed => + 'Could not finish removing the account. Try again.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'The account was removed from this device, but BusyMax could not revoke Google access. You can revoke it from your Google Account.'; + + @override + String get newTaskList => 'New task list'; + + @override + String taskListCreateFailed(String error) { + return 'Could not create the task list: $error'; + } + + @override + String taskListRenameFailed(String error) { + return 'Could not rename the task list: $error'; + } + + @override + String taskListDeleteFailed(String error) { + return 'Could not delete the task list: $error'; + } + + @override + String get signInToViewTaskLists => 'Sign in to view task lists.'; + + @override + String get noTaskListsSynced => 'No task lists synced yet.'; + + @override + String get listActions => 'List actions'; + + @override + String get rename => 'Rename'; + + @override + String get delete => 'Delete'; + + @override + String get renameList => 'Rename list'; + + @override + String get deleteList => 'Delete list'; + + @override + String get unshare => 'Unshare'; + + @override + String get readOnlyTaskListCannotRename => + 'This task list is read-only and cannot be renamed.'; + + @override + String get taskListCannotDelete => + 'This task list cannot be deleted with your current permissions.'; + + @override + String get builtInMicrosoftList => 'Built-in'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Built-in Microsoft To Do lists cannot be renamed or deleted.'; + + @override + String deleteListConfirmation(String title) { + return 'Delete \"$title\" from Google Tasks?'; + } + + @override + String deleteTaskListConfirmation(String title) { + return 'Delete \"$title\" and all of its tasks?'; + } + + @override + String unshareTaskListConfirmation(String title) { + return 'Unshare \"$title\" from this account?'; + } + + @override + String get deleteEvent => 'Delete Event'; + + @override + String get title => 'Title'; + + @override + String get create => 'Create'; + + @override + String get newTask => 'New task'; + + @override + String get clearCompleted => 'Clear completed'; + + @override + String get refreshList => 'Refresh list'; + + @override + String get refreshAll => 'Refresh all'; + + @override + String get listRefreshed => 'List refreshed.'; + + @override + String get allTasksRefreshed => 'All accounts refreshed.'; + + @override + String exportedFile(String path) { + return 'Exported to $path'; + } + + @override + String exportFailed(String error) { + return 'Export failed: $error'; + } + + @override + String refreshFailed(String error) { + return 'Refresh failed: $error'; + } + + @override + String get selectOrCreateTaskList => 'Select or create a task list to begin.'; + + @override + String get signInToViewTasks => 'Sign in to view tasks.'; + + @override + String get noTasks => 'No tasks.'; + + @override + String get noTasksYet => 'No tasks yet'; + + @override + String get noTasksYetMessage => + 'Create a task or refresh your accounts to get started.'; + + @override + String get noTasksInList => 'No tasks in this list.'; + + @override + String get overdue => 'Overdue'; + + @override + String get today => 'Today'; + + @override + String get tomorrow => 'Tomorrow'; + + @override + String get upcoming => 'Upcoming'; + + @override + String get noDate => 'No date'; + + @override + String get completed => 'Completed'; + + @override + String duePrefix(String date) { + return 'Due $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => 'Task details'; + + @override + String get editTask => 'Edit Task'; + + @override + String get noTaskSelected => 'No task selected.'; + + @override + String get noTaskSelectedHelper => 'Select a task to view and edit details.'; + + @override + String get taskUnavailable => 'Task unavailable.'; + + @override + String get signInToEditTasks => 'Sign in to edit tasks.'; + + @override + String get refreshTask => 'Refresh task'; + + @override + String get primarySection => 'Primary'; + + @override + String get statusSection => 'Status'; + + @override + String get openStatus => 'Open'; + + @override + String get doneStatus => 'Done'; + + @override + String get taskStatus => 'Status'; + + @override + String get taskStatusNone => 'No status'; + + @override + String get taskStatusNeedsAction => 'Needs action'; + + @override + String get taskStatusInProcess => 'In progress'; + + @override + String get taskStatusCompleted => 'Completed'; + + @override + String get taskStatusCancelled => 'Cancelled'; + + @override + String completionPercent(int percent) { + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% completed'; + } + + @override + String get completionDate => 'Completion date'; + + @override + String get priority => 'Priority'; + + @override + String get priorityNone => 'No priority'; + + @override + String priorityHighValue(int priority) { + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priority $priorityString · High'; + } + + @override + String priorityMediumValue(int priority) { + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priority $priorityString · Medium'; + } + + @override + String priorityLowValue(int priority) { + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priority $priorityString · Low'; + } + + @override + String get taskUrl => 'URL'; + + @override + String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + + @override + String get classification => 'Classification'; + + @override + String get classificationPublic => 'When shared, show the full task'; + + @override + String get classificationConfidential => 'When shared, show only busy'; + + @override + String get classificationPrivate => 'When shared, hide this task'; + + @override + String get pinTask => 'Pin task'; + + @override + String get notes => 'Notes'; + + @override + String get dueDate => 'Due date'; + + @override + String get clearDueDate => 'Clear due date'; + + @override + String get dueTime => 'Due time'; + + @override + String get startDate => 'Start date'; + + @override + String get startTime => 'Start time'; + + @override + String get endDate => 'End Date'; + + @override + String get endTime => 'End Time'; + + @override + String get reminderDate => 'Reminder date'; + + @override + String get reminderTime => 'Reminder time'; + + @override + String get reminder => 'Reminder'; + + @override + String get addReminder => 'Add Reminder'; + + @override + String get reminders => 'Reminders'; + + @override + String get noReminders => 'No reminders'; + + @override + String get editReminder => 'Edit reminder'; + + @override + String get beforeTaskStarts => 'Before the task starts'; + + @override + String get beforeTaskDue => 'Before the task is due'; + + @override + String get afterTaskStarts => 'After the task starts'; + + @override + String get afterTaskDue => 'After the task is due'; + + @override + String get relativeToTaskStart => 'Relative to the task start date'; + + @override + String get relativeToTaskDue => 'Relative to the task due date'; + + @override + String get reminderTimeOfDay => 'Time of day'; + + @override + String get absoluteReminder => 'At a date and time'; + + @override + String get reminderAmount => 'Amount'; + + @override + String get reminderUnit => 'Unit'; + + @override + String get reminderUnitSeconds => 'Seconds'; + + @override + String get reminderUnitMinutes => 'Minutes'; + + @override + String get reminderUnitHours => 'Hours'; + + @override + String get reminderUnitDays => 'Days'; + + @override + String get reminderUnitWeeks => 'Weeks'; + + @override + String get reminderAtTaskStart => 'At the task start'; + + @override + String get reminderAtTaskDue => 'At the task due time'; + + @override + String get unsupportedReminder => + 'This reminder type is preserved but its time cannot be edited.'; + + @override + String get relatedRemindersTitle => 'Keep related reminders?'; + + @override + String relatedRemindersDescription(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'This date has $countString related reminders. Keep them at their current date and time?'; + } + + @override + String get discardRelatedReminders => 'Discard reminders'; + + @override + String get keepRelatedReminders => 'Keep reminders'; + + @override + String get addGuest => 'Add Guest'; + + @override + String get addGuestEmail => 'Add guest email'; + + @override + String get removeReminder => 'Remove reminder'; + + @override + String get off => 'Off'; + + @override + String get repeat => 'Repeat'; + + @override + String get repeatNone => 'None'; + + @override + String get noneValue => 'None'; + + @override + String get repeatDaily => 'Daily'; + + @override + String get repeatWeekly => 'Weekly'; + + @override + String get repeatMonthly => 'Monthly'; + + @override + String get repeatYearly => 'Yearly'; + + @override + String get repeatEvery => 'Interval'; + + @override + String get repeatOn => 'Repeat on'; + + @override + String get repeatEnd => 'End repeat'; + + @override + String get repeatNever => 'Never'; + + @override + String get repeatUntil => 'On date'; + + @override + String get repeatAfter => 'After a number of occurrences'; + + @override + String get repeatCount => 'Occurrences'; + + @override + String get repeatDayOfMonth => 'Days of month'; + + @override + String get repeatMonths => 'Months'; + + @override + String get repeatOrdinal => 'Weekday position'; + + @override + String get repeatSpecificDays => 'Specific days'; + + @override + String get repeatFirst => 'First'; + + @override + String get repeatSecond => 'Second'; + + @override + String get repeatThird => 'Third'; + + @override + String get repeatFourth => 'Fourth'; + + @override + String get repeatFifth => 'Fifth'; + + @override + String get repeatSecondToLast => 'Second to last'; + + @override + String get repeatLast => 'Last'; + + @override + String get repeatAnyDay => 'Day'; + + @override + String get repeatWeekday => 'Weekday'; + + @override + String get repeatWeekendDay => 'Weekend day'; + + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'Monday', + 'TU': 'Tuesday', + 'WE': 'Wednesday', + 'TH': 'Thursday', + 'FR': 'Friday', + 'SA': 'Saturday', + 'SU': 'Sunday', + 'day': 'day', + 'weekday': 'weekday', + 'weekend': 'weekend day', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatEveryDays(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Every $countString days'; + } + + @override + String repeatEveryWeeks(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Every $countString weeks'; + } + + @override + String repeatEveryMonths(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Every $countString months'; + } + + @override + String repeatEveryYears(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Every $countString years'; + } + + @override + String repeatOnDaysSummary(String days) { + return 'on $days'; + } + + @override + String repeatOnMonthDaysSummary(String days) { + return 'on day $days'; + } + + @override + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'on the first $days', + 'second': 'on the second $days', + 'third': 'on the third $days', + 'fourth': 'on the fourth $days', + 'fifth': 'on the fifth $days', + 'secondToLast': 'on the second to last $days', + 'last': 'on the last $days', + 'other': 'on $days', + }); + return '$_temp0'; + } + + @override + String repeatInMonthsSummary(String months) { + return 'in $months'; + } + + @override + String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString times', + one: '$countString time', + ); + return '$_temp0'; + } + + @override + String repeatUntilSummary(String date) { + return 'until $date'; + } + + @override + String get unsupportedRecurrencePreserved => + 'This recurrence rule uses options that this editor does not change.'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'This recurrence cannot be used with $provider.'; + } + + @override + String get importance => 'Importance'; + + @override + String get importanceLow => 'Low'; + + @override + String get importanceNormal => 'Normal'; + + @override + String get importanceHigh => 'High'; + + @override + String get categories => 'Categories'; + + @override + String get scheduleSection => 'Schedule'; + + @override + String get dueGroup => 'Due'; + + @override + String get startGroup => 'Start'; + + @override + String get reminderGroup => 'Reminder'; + + @override + String get organizationSection => 'Organization'; + + @override + String get actionsSection => 'Actions'; + + @override + String get advancedSection => 'Advanced'; + + @override + String get addCategory => 'Add category'; + + @override + String get list => 'List'; + + @override + String get microsoftMoveUnsupported => + 'Moving between lists is not supported for Microsoft To Do accounts in this version.'; + + @override + String get createSubtask => 'Create subtask'; + + @override + String get subtasks => 'Subtasks'; + + @override + String get duplicateTask => 'Duplicate task'; + + @override + String get taskDuplicated => 'Task duplicated.'; + + @override + String taskDuplicateFailed(String error) { + return 'Could not duplicate the task: $error'; + } + + @override + String get hideSubtasks => 'Hide subtasks'; + + @override + String get hideClosedSubtasks => 'Hide closed subtasks'; + + @override + String get moveToTop => 'Move to top'; + + @override + String get deleteTask => 'Delete Task'; + + @override + String get newSubtask => 'New subtask'; + + @override + String deleteTaskConfirmation(String title) { + return 'Delete \"$title\"?'; + } + + @override + String get metadata => 'Metadata'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'Updated'; + + @override + String get parent => 'Parent'; + + @override + String get position => 'Position'; + + @override + String get webLink => 'Web link'; + + @override + String get assignment => 'Assignment'; + + @override + String get localState => 'Local state'; + + @override + String get pendingSync => 'Pending sync'; + + @override + String get synced => 'Synced'; + + @override + String get account => 'Account'; + + @override + String get sync => 'Sync'; + + @override + String get forceFullResync => 'Force full resync'; + + @override + String get forceFullResyncDescription => + 'Completely reload data from every connected account. Use this only to troubleshoot synchronization problems.'; + + @override + String get runInBackgroundWhenClosed => + 'Continue running when the window is closed'; + + @override + String get showTrayIcon => 'Show tray icon'; + + @override + String get startMinimizedToTray => 'Start minimized to the tray'; + + @override + String get launchAtLogin => 'Launch at login'; + + @override + String get launchAtLoginDescription => + 'Start BusyMax in the background so reminders work after you sign in.'; + + @override + String get launchAtLoginFailed => + 'Could not update the launch-at-login setting.'; + + @override + String get requiresTrayIcon => 'Requires the tray icon.'; + + @override + String get syncComplete => 'Sync complete.'; + + @override + String syncFailed(String error) { + return 'Sync failed: $error'; + } + + @override + String get notifySyncFailures => 'Notifications on sync failure'; + + @override + String get notifyConflicts => 'Notifications on conflicts'; + + @override + String get notifyDueToday => 'Due-today notifications'; + + @override + String get eventReminders => 'Event reminders'; + + @override + String get onState => 'On'; + + @override + String get taskReminders => 'Task reminders'; + + @override + String get notificationDetailLevel => 'Notification detail level'; + + @override + String get notificationDetailPrivate => 'Private'; + + @override + String get notificationDetailNormal => 'Normal'; + + @override + String get quietHours => 'Quiet hours'; + + @override + String get quietHoursDescription => 'Pause notifications during this period.'; + + @override + String get quietHoursStart => 'Quiet hours start'; + + @override + String get quietHoursEnd => 'Quiet hours end'; + + @override + String get notifications => 'Notifications'; + + @override + String get windowsNotificationsUnavailable => + 'Windows notifications are unavailable'; + + @override + String get windowsNotificationsUnpackaged => + 'This unpackaged development run cannot use Windows notifications. Install the test-signed MSIX to test reminders.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax could not initialize Windows notifications. Reminders will not appear until this installation problem is resolved.'; + + @override + String get appearance => 'Appearance'; + + @override + String get theme => 'Theme'; + + @override + String get themeSystem => 'System'; + + @override + String get settingsSystem => 'System'; + + @override + String get themeLight => 'Light'; + + @override + String get themeDark => 'Dark'; + + @override + String get themeFamily => 'Theme family'; + + @override + String get themeFamilyYaru => 'Native Ubuntu (Yaru)'; + + @override + String get localization => 'Localization'; + + @override + String get currentLocale => 'Current locale'; + + @override + String get privacy => 'Privacy'; + + @override + String get redactTaskContentInDiagnostics => + 'Redact task content in diagnostics'; + + @override + String get developerDiagnostics => 'Developer diagnostics'; + + @override + String get diagnostics => 'Diagnostics'; + + @override + String get apiInspectorDisabled => 'Show API inspector'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'Discovery revision: $revision'; + } + + @override + String get implementedMethods => 'Implemented methods'; + + @override + String get supportsTasksScopes => 'Supports tasks and tasks.readonly scopes'; + + @override + String get requiresTasksScope => 'Requires tasks scope'; + + @override + String get blockedPendingOperations => 'Blocked pending operations'; + + @override + String get signInToInspectPendingOperations => + 'Sign in to inspect pending operations.'; + + @override + String get noBlockedPendingOperations => 'No blocked pending operations.'; + + @override + String get operationActions => 'Operation actions'; + + @override + String pendingOpListId(String id) { + return 'list=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'task=$id'; + } + + @override + String pendingOpAttempts(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'attempts=$countString'; + } + + @override + String get retry => 'Retry'; + + @override + String get discard => 'Discard'; + + @override + String get discardChangesAction => 'Discard'; + + @override + String get discardChanges => 'Discard changes?'; + + @override + String get discardChangesConfirmation => + 'This discards unsaved edits to this task.'; + + @override + String get retryCompleted => 'Retry completed.'; + + @override + String get discardPendingOperation => 'Discard pending operation?'; + + @override + String get discardPendingOperationConfirmation => + 'This removes the blocked local operation. The next sync will refresh from Google Tasks.'; + + @override + String get pendingOperationDiscarded => 'Pending operation discarded.'; + + @override + String get syncFailureNotificationTitle => 'BusyMax sync failed'; + + @override + String syncFailureNotificationBody(String message) { + return 'Background sync failed. $message'; + } + + @override + String get conflictNotificationTitle => 'BusyMax sync conflict'; + + @override + String conflictNotificationBody(String summary) { + return 'A pending local change was blocked. $summary'; + } + + @override + String get dueTodayNotificationTitle => 'Tasks due today'; + + @override + String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString tasks are due today.', + one: 'One task is due today.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'Event reminder'; + + @override + String get taskReminderNotificationTitle => 'Task reminder'; + + @override + String get eventReminderNotificationBody => 'Event starts soon.'; + + @override + String get taskReminderNotificationBody => 'Task is due soon.'; + + @override + String get notificationOpenAction => 'Open'; + + @override + String get notificationSnoozeAction => 'Snooze 10 minutes'; + + @override + String get notificationDismissAction => 'Dismiss'; + + @override + String get notificationDetailsHidden => + 'Details are hidden by privacy settings.'; + + @override + String get previousMonth => 'Previous month'; + + @override + String get nextMonth => 'Next month'; + + @override + String get openMonthView => 'Open month view'; + + @override + String get previousYear => 'Previous year'; + + @override + String get nextYear => 'Next year'; + + @override + String get openYearView => 'Open year view'; + + @override + String weekNumberTooltip(int number) { + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Week $numberString'; + } + + @override + String get resizeAllDayPanel => 'Resize the all-day panel'; + + @override + String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString items', + one: '1 item', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'This calendar is read-only.'; + + @override + String get selectTimeZone => 'Select Timezone'; + + @override + String get searchLocations => 'Search locations'; + + @override + String get noLocationsFound => 'No locations found'; + + @override + String get requiredField => 'This field is required.'; + + @override + String get providerConnectionDescription => + 'Connect calendars and tasks from one of these providers.'; + + @override + String get appleICloudProvider => 'Apple iCloud Calendar'; + + @override + String get nextcloudProvider => 'Nextcloud'; + + @override + String get appleICloudTasksProvider => 'Apple iCloud'; + + @override + String get nextcloudTasksProvider => 'Nextcloud Tasks'; + + @override + String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + + @override + String get addNextcloudAccount => 'Add Nextcloud account'; + + @override + String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + + @override + String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + + @override + String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + + @override + String get appleAccountEmail => 'Apple Account email'; + + @override + String get appleAppSpecificPassword => 'App-specific password'; + + @override + String get appleAppSpecificPasswordHelp => + 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + + @override + String get appleAppSpecificPasswordResetWarning => + 'Resetting your Apple Account password revokes app-specific passwords.'; + + @override + String get connectNextcloudTitle => 'Connect Nextcloud'; + + @override + String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + + @override + String get nextcloudServerUrlHelp => + 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + + @override + String get nextcloudBrowserAuthorizationHelp => + 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + + @override + String get connectAccountAction => 'Connect'; + + @override + String get cancelAccountConnection => 'Cancel connection'; + + @override + String get nextcloudAccountRemovedRevokeFailed => + 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + + @override + String get davCachedOfflineNotice => + 'Calendar and task data is cached locally for offline use.'; + + @override + String get davReauthenticationRequired => + 'Reconnect this account to resume synchronization.'; + + @override + String get davTemporarilyUnavailable => + 'This account is temporarily unavailable.'; + + @override + String get davPermissionChanged => + 'Server permissions changed. Pending edits are paused.'; + + @override + String get davUnsupportedServer => + 'This server or provider profile is not supported.'; + + @override + String get collectionSettings => 'Calendars and task lists'; + + @override + String get calendarContent => 'Calendar events'; + + @override + String get taskContent => 'Tasks'; + + @override + String get readOnlySharedCollection => 'Read-only'; + + @override + String get pendingLocally => 'Pending locally'; + + @override + String get conflictBlocked => 'Blocked by conflict'; + + @override + String get authenticationBlocked => 'Blocked until reconnect'; + + @override + String get operationFailed => 'Operation failed'; + + @override + String get keepServerVersion => 'Keep server version'; + + @override + String get reapplyLocalChange => 'Review and reapply local change'; + + @override + String get duplicateLocalItem => 'Duplicate as new item'; + + @override + String get davConnectionState => 'Connection state'; + + @override + String get davConnected => 'Connected'; + + @override + String get davConnecting => 'Connecting…'; + + @override + String get davSignedOut => 'Signed out'; + + @override + String davLastSuccessfulSync(String time) { + return 'Last successful sync: $time'; + } + + @override + String get davNeverSynced => 'Not synchronized yet'; + + @override + String get refreshCollections => 'Refresh calendars and task lists'; + + @override + String nextcloudServerHost(String host) { + return 'Server: $host'; + } + + @override + String get collectionSupportsEvents => 'Event calendar'; + + @override + String get collectionSupportsTasks => 'Task list'; + + @override + String get collectionSupportsEventsAndTasks => 'Events and tasks'; + + @override + String get writableCollection => 'Writable'; + + @override + String get sharedCollection => 'Shared'; + + @override + String collectionLastSynced(String time) { + return 'Last synchronized: $time'; + } + + @override + String collectionSyncError(String code) { + return 'Sync issue: $code'; + } + + @override + String get syncConflicts => 'Synchronization conflicts'; + + @override + String remoteChangedAt(String time) { + return 'Server changed: $time'; + } + + @override + String localPendingEdit(String summary) { + return 'Local edit: $summary'; + } + + @override + String get conflictResolutionFailed => 'The conflict could not be resolved.'; + + @override + String get recurringEventScope => 'Recurring event scope'; + + @override + String get entireSeries => 'Entire series'; + + @override + String get singleOccurrence => 'This event'; + + @override + String get thisAndFollowingEvents => 'This and following events'; + + @override + String get thisAndFutureUnavailable => 'Not supported by this provider.'; + + @override + String get thisAndFutureMoveUnavailable => + 'This and following events cannot be moved safely. Choose this event or the entire series.'; + + @override + String get entireSeriesMoveUnavailable => + 'The recurrence rule is not available locally. Move this event instead.'; + + @override + String get copyEventAndDeleteOriginal => 'Copy event and delete original?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax cannot move this event directly from $source to $destination. It will create the copy first and delete the original only after the copy succeeds. Event IDs will change; attendee response statuses may reset and invitations or cancellations may be sent; conference links, attachments, reminders, provider-specific fields, and recurrence exceptions may not carry over.'; + } + + @override + String get copyAndDelete => 'Copy and delete'; + + @override + String get chooseRecurringEventScope => + 'Choose whether this change applies to the entire series, only this occurrence, or this and following events.'; + + @override + String get taskDueBeforeStart => 'Due must not be before start.'; + + @override + String get taskStartDueTimeModeMismatch => + 'Set times for both start and due, or make the task all day.'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Delete \"$title\"?'; + } + + @override + String get setCustomCalendarName => 'Set custom name'; + + @override + String get setAction => 'Set'; + + @override + String get removeFromMyCalendars => 'Remove from my calendars'; + + @override + String get removeAction => 'Remove'; + + @override + String removeCalendarConfirmation(String title) { + return 'Remove \"$title\" from your Google Calendar list? The shared calendar and its events will not be deleted.'; + } + + @override + String get calendarCannotRemove => + 'This calendar cannot be deleted or removed from this account.'; + + @override + String get calendarPendingChangesPreventRemoval => + 'Wait for this calendar’s pending changes to finish syncing before deleting or removing it.'; + + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get calendarImport => 'Calendar import'; + + @override + String get calendarImportDescription => + 'Select a file, review its events, then choose the writable calendar that should receive them.'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Importable event sets: $countString'; + } + + @override + String importInvalidEvents(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Invalid events: $countString'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Imported or queued: $countString'; + } + + @override + String importDuplicatesSkipped(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Duplicates skipped: $countString'; + } + + @override + String importUnsupportedSets(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Unsupported recurrence sets: $countString'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + + @override + String get networkOffline => 'Offline'; + + @override + String get networkOfflineDescription => + 'Changes will sync when the connection is restored.'; + + @override + String get networkOfflineTryAgain => + 'You’re offline. Connect to the internet and try again.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'on days $days'; + } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(String day) { + return '$day'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first and $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first and $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first and $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first and $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency on $month $day'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency on days $days of $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency on day $day of $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency on days $days of $months'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'on the first $days of $month', + 'second': 'on the second $days of $month', + 'third': 'on the third $days of $month', + 'fourth': 'on the fourth $days of $month', + 'fifth': 'on the fifth $days of $month', + 'secondToLast': 'on the second to last $days of $month', + 'last': 'on the last $days of $month', + 'other': 'on $days of $month', + }); + return '$frequency $_temp0'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'on the first $days of $months', + 'second': 'on the second $days of $months', + 'third': 'on the third $days of $months', + 'fourth': 'on the fourth $days of $months', + 'fifth': 'on the fifth $days of $months', + 'secondToLast': 'on the second to last $days of $months', + 'last': 'on the last $days of $months', + 'other': 'on $days of $months', + }); + return '$frequency $_temp0'; + } +} + +/// The translations for Portuguese, as used in Portugal (`pt_PT`). +class AppLocalizationsPtPt extends AppLocalizationsPt { + AppLocalizationsPtPt() : super('pt_PT'); + + @override + String get windowsSupport => 'Suporte'; + + @override + String get windowsThirdPartyLicenses => 'Licenças de terceiros'; + + @override + String get windowsSearch => 'Pesquisar'; + + @override + String get windowsStartupDisabledByUser => + 'Desativado pelo utilizador nas Definições do Windows.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Desativado por uma política do Windows.'; + + @override + String get windowsStartupUnavailable => + 'Disponível após a instalação do BusyMax a partir de um pacote MSIX.'; + + @override + String get windowsReminderExitNotice => + 'Os lembretes param quando o BusyMax é totalmente encerrado. Mantenha-o em execução em segundo plano para os receber.'; + + @override + String get windowsProductVersionLabel => 'Versão do produto'; + + @override + String get windowsPackageVersionLabel => 'Versão do pacote do Windows'; + + @override + String get windowsUnpackaged => 'Sem pacote'; + + @override + String get windowsAgendaLoadMore => 'Carregar mais itens da agenda'; + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'à segunda-feira', + 'TU': 'à terça-feira', + 'WE': 'à quarta-feira', + 'TH': 'à quinta-feira', + 'FR': 'à sexta-feira', + 'SA': 'ao sábado', + 'SU': 'ao domingo', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'nos dias $first e $second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency nos dias $firstDay e $secondDay de $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency no dia $day de $firstMonth e $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency nos dias $firstDay e $secondDay de $firstMonth e $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency nos dias $days de $firstMonth e $secondMonth'; + } + @override String get appTitle => 'BusyMax'; @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Ligue as contas Google, Microsoft, Calendário Apple iCloud ou Nextcloud.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +2815,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Adicione todas as contas que pretende utilizar. O BusyMax sincroniza calendários, eventos, listas de tarefas e tarefas suportados de cada conta.'; @override String get onboardingPreferencesStepTitle => 'Escolher definições do sistema'; @@ -119,6 +2893,44 @@ class AppLocalizationsPt extends AppLocalizations { @override String get calendars => 'Calendários'; + @override + String get newCalendar => 'Novo calendário'; + + @override + String get calendarColor => 'Cor do calendário'; + + @override + String calendarColorOption(int number) { + return 'Cor $number'; + } + + @override + String get calendarManagementUnsupported => + 'Este fornecedor não suporta a gestão de calendários no BusyMax.'; + + @override + String get primaryCalendarCannotDelete => + 'O calendário principal não pode ser eliminado.'; + + @override + String calendarCreateFailed(String error) { + return 'Não foi possível criar o calendário: $error'; + } + + @override + String get calendarCreatedRefreshPending => + 'O calendário foi criado, mas o BusyMax não conseguiu atualizar a conta. Será apresentado após a próxima sincronização.'; + + @override + String calendarUpdateFailed(String error) { + return 'Não foi possível atualizar o calendário: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Não foi possível eliminar o calendário: $error'; + } + @override String get newEvent => 'Novo evento'; @@ -142,7 +2954,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String moreItems(int count) { - return '+$count mais'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString mais'; } @override @@ -183,6 +2999,101 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trayOpenBusyMax => 'Abrir o BusyMax'; + @override + String get trayShowBusyMax => 'Mostrar o BusyMax'; + + @override + String get trayNewEvent => 'Novo evento…'; + + @override + String get trayNewTask => 'Nova tarefa…'; + + @override + String get trayToday => 'Hoje'; + + @override + String get trayAllDay => 'Todo o dia'; + + @override + String get trayNow => 'Agora'; + + @override + String get trayCalendarEvent => 'Evento do calendário'; + + @override + String get trayUntitledEvent => 'Evento sem título'; + + @override + String get trayNothingElseToday => 'Nada mais hoje'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tarefas terminam hoje', + one: '1 tarefa termina hoje', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Abrir a agenda de hoje'; + + @override + String get traySyncNow => 'Sincronizar agora'; + + @override + String get traySyncing => 'A sincronizar…'; + + @override + String get trayNotConnected => 'Não ligado'; + + @override + String get trayNotYetSynced => 'Ainda não sincronizado'; + + @override + String get trayLastSyncedJustNow => 'Sincronizado agora mesmo'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Sincronizado há $count minutos', + one: 'Sincronizado há 1 minuto', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Sincronizado há $count horas', + one: 'Sincronizado há 1 hora', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Sincronizado há $count dias', + one: 'Sincronizado há 1 dia', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Definições'; + + @override + String get trayQuitBusyMax => 'Sair do BusyMax'; + @override String get agendaLoadMoreOverdue => 'Carregar mais tarefas em atraso'; @@ -202,7 +3113,7 @@ class AppLocalizationsPt extends AppLocalizations { String get viewYear => 'Ano'; @override - String get viewAgenda => 'Agenda'; + String get viewAgenda => 'Vista de agenda'; @override String get scheduleSettings => 'Agenda'; @@ -266,127 +3177,128 @@ class AppLocalizationsPt extends AppLocalizations { String get noGuests => 'Sem convidados'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Obrigatório'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Opcional'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Reunião'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Adicionar o Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Adicionar reunião do Microsoft Teams'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Reunião online adicionada'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Pedir respostas'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'Peça aos convidados que respondam ao convite.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Ocultar lista de convidados'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'Os convidados não podem ver quem mais foi convidado.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'Permitir novas propostas de horário'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Os convidados podem sugerir uma hora diferente para a reunião.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'Notificar convidados?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'Esta reunião tem convidados. Enviar convites ou atualizações do evento ao guardá-la?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'Esta reunião tem convidados. Enviar um cancelamento ao eliminá-la?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Enviar atualizações'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Enviar cancelamento'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'Não enviar'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'Guardar reunião?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'A Microsoft enviará convites ou atualizações do evento aos convidados.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'Eliminar reunião?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'A Microsoft enviará um cancelamento aos convidados.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Organizador'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'A sua resposta'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Respostas dos convidados'; @override - String get respond => 'Respond'; + String get respond => 'Responder'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Aceitar'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Provisório'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Recusar'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'Participar na reunião'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Aceite'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Provisório'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Recusado'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'A aguardar resposta'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Sem resposta'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Organizador'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'Não foi possível enviar a sua resposta: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => + 'Não foi possível abrir a ligação da reunião.'; @override String get description => 'Descrição'; @@ -432,10 +3344,14 @@ class AppLocalizationsPt extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes minutos antes', + other: '$minutesString minutos antes', one: '1 minuto antes', ); return '$_temp0'; @@ -446,10 +3362,14 @@ class AppLocalizationsPt extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours horas antes', + other: '$hoursString horas antes', one: '1 hora antes', ); return '$_temp0'; @@ -457,10 +3377,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days dias antes', + other: '$daysString dias antes', one: '1 dia antes', ); return '$_temp0'; @@ -647,7 +3572,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Adiciona apenas a versão do sistema operativo Linux e a configuração regional da aplicação. Não são incluídos registos, dados de contas, nomes de ficheiros nem outros diagnósticos.'; + 'Adiciona apenas o nome e a versão do sistema operativo e a configuração regional da aplicação. Não são incluídos registos, dados de contas, nomes de ficheiros nem outros diagnósticos.'; @override String get feedbackCategoryRequired => 'Selecione uma categoria.'; @@ -738,7 +3663,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'Esta ação elimina deste dispositivo as tarefas, calendários, eventos, lembretes e alterações offline pendentes colocados em cache. As alterações não sincronizadas serão perdidas. As cópias dos calendários, eventos, listas de tarefas e tarefas no fornecedor não são eliminadas.'; @override String get revokeGoogleAccess => @@ -764,17 +3689,17 @@ class AppLocalizationsPt extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'Não foi possível criar a lista de tarefas: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'Não foi possível mudar o nome da lista de tarefas: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'Não foi possível eliminar a lista de tarefas: $error'; } @override @@ -805,11 +3730,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'Esta lista de tarefas é só de leitura e não pode ter o nome alterado.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'Esta lista de tarefas não pode ser eliminada com as permissões atuais.'; @override String get builtInMicrosoftList => 'Incorporada'; @@ -820,17 +3745,17 @@ class AppLocalizationsPt extends AppLocalizations { @override String deleteListConfirmation(String title) { - return 'Eliminar «$title» do Google Tasks?'; + return 'Eliminar “$title” do Google Tasks?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return 'Eliminar “$title” e todas as suas tarefas?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'Cancelar a partilha de “$title” desta conta?'; } @override @@ -958,72 +3883,91 @@ class AppLocalizationsPt extends AppLocalizations { String get doneStatus => 'Concluída'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'Estado'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Sem estado'; @override - String get taskStatusNeedsAction => 'Needs action'; + String get taskStatusNeedsAction => 'Requer ação'; @override - String get taskStatusInProcess => 'Em andamento'; + String get taskStatusInProcess => 'Em curso'; @override String get taskStatusCompleted => 'Concluída'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Cancelada'; @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% concluída'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Data de conclusão'; @override String get priority => 'Prioridade'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Sem prioridade'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioridade $priorityString · Alta'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioridade $priorityString · Média'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioridade $priorityString · Baixa'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'URL da tarefa'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => + 'Introduza um URL absoluto, incluindo o esquema.'; @override - String get classification => 'Classification'; + String get classification => 'Classificação'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => + 'Quando partilhada, mostrar a tarefa completa'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => + 'Quando partilhada, mostrar apenas ocupado'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'Quando partilhada, ocultar esta tarefa'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Afixar tarefa'; @override String get notes => 'Notas'; @@ -1062,82 +4006,86 @@ class AppLocalizationsPt extends AppLocalizations { String get addReminder => 'Adicionar lembrete'; @override - String get reminders => 'Reminders'; + String get reminders => 'Lembretes'; @override - String get noReminders => 'No reminders'; + String get noReminders => 'Sem lembretes'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Editar lembrete'; @override - String get beforeTaskStarts => 'Before the task starts'; + String get beforeTaskStarts => 'Antes do início da tarefa'; @override - String get beforeTaskDue => 'Before the task is due'; + String get beforeTaskDue => 'Antes do prazo da tarefa'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'Depois do início da tarefa'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'Depois do prazo da tarefa'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'Relativo à data de início da tarefa'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'Relativo à data limite da tarefa'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Hora do dia'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'Numa data e hora'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Quantidade'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Unidade'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Segundos'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'Minutos'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Horas'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Dias'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Semanas'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'No início da tarefa'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'No prazo da tarefa'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'Este tipo de lembrete é preservado, mas a hora não pode ser editada.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'Manter lembretes relacionados?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Esta data tem $countString lembretes relacionados. Mantê-los na data e hora atuais?'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => 'Eliminar lembretes'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => 'Manter lembretes'; @override String get addGuest => 'Adicionar convidado'; @@ -1173,111 +4121,165 @@ class AppLocalizationsPt extends AppLocalizations { String get repeatYearly => 'Anualmente'; @override - String get repeatEvery => 'Repetir a cada'; + String get repeatEvery => 'Intervalo'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'Repetir em'; @override - String get repeatEnd => 'End repeat'; + String get repeatEnd => 'Terminar repetição'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'Nunca'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'Numa data'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'Depois de um número de ocorrências'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'Ocorrências'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'Dias do mês'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'Meses'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'Posição do dia da semana'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'Dias específicos'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'Primeiro'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'Segundo'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'Terceiro'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'Quarto'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'Quinto'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'Penúltimo'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'Último'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'Dia'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'Dia útil'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'Dia do fim de semana'; + + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'segunda-feira', + 'TU': 'terça-feira', + 'WE': 'quarta-feira', + 'TH': 'quinta-feira', + 'FR': 'sexta-feira', + 'SA': 'sábado', + 'SU': 'domingo', + 'day': 'dia', + 'weekday': 'dia útil', + 'weekend': 'dia do fim de semana', + 'other': '$day', + }); + return '$_temp0'; + } @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'A cada $countString dias'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'A cada $countString semanas'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'A cada $countString meses'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'A cada $countString anos'; } @override String repeatOnDaysSummary(String days) { - return 'em $days'; + return '$days'; } @override String repeatOnMonthDaysSummary(String days) { - return 'on day $days'; + return 'no dia $days'; } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'na primeira ocorrência de $days', + 'second': 'na segunda ocorrência de $days', + 'third': 'na terceira ocorrência de $days', + 'fourth': 'na quarta ocorrência de $days', + 'fifth': 'na quinta ocorrência de $days', + 'secondToLast': 'na penúltima ocorrência de $days', + 'last': 'na última ocorrência de $days', + 'other': 'em $days', + }); + return '$_temp0'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return 'em $months'; } @override String repeatTimesSummary(int count) { - return '$count vezes'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString vezes', + one: '$countString vez', + ); + return '$_temp0'; } @override @@ -1287,7 +4289,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'Esta regra de recorrência utiliza opções que este editor não altera.'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'Esta recorrência não pode ser usada com $provider.'; + } @override String get importance => 'Importância'; @@ -1296,7 +4303,7 @@ class AppLocalizationsPt extends AppLocalizations { String get importanceLow => 'Baixa'; @override - String get importanceNormal => 'Normal'; + String get importanceNormal => 'Média'; @override String get importanceHigh => 'Alta'; @@ -1342,21 +4349,21 @@ class AppLocalizationsPt extends AppLocalizations { String get subtasks => 'Subtarefas'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'Duplicar tarefa'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Tarefa duplicada.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'Não foi possível duplicar a tarefa: $error'; } @override String get hideSubtasks => 'Ocultar subtarefas'; @override - String get hideClosedSubtasks => 'Hide closed subtasks'; + String get hideClosedSubtasks => 'Ocultar subtarefas concluídas'; @override String get moveToTop => 'Mover para o início'; @@ -1412,7 +4419,11 @@ class AppLocalizationsPt extends AppLocalizations { String get sync => 'Sincronização'; @override - String get manualFullSync => 'Sincronização completa manual'; + String get forceFullResync => 'Forçar nova sincronização completa'; + + @override + String get forceFullResyncDescription => + 'Recarrega completamente os dados de todas as contas ligadas. Utilize esta opção apenas para resolver problemas de sincronização.'; @override String get runInBackgroundWhenClosed => @@ -1459,6 +4470,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get eventReminders => 'Lembretes de eventos'; + @override + String get onState => 'Ativado'; + @override String get taskReminders => 'Lembretes de tarefas'; @@ -1469,7 +4483,7 @@ class AppLocalizationsPt extends AppLocalizations { String get notificationDetailPrivate => 'Privado'; @override - String get notificationDetailNormal => 'Normal'; + String get notificationDetailNormal => 'Predefinido'; @override String get quietHours => 'Período de silêncio'; @@ -1487,6 +4501,18 @@ class AppLocalizationsPt extends AppLocalizations { @override String get notifications => 'Notificações'; + @override + String get windowsNotificationsUnavailable => + 'As notificações do Windows não estão disponíveis'; + + @override + String get windowsNotificationsUnpackaged => + 'Esta execução de desenvolvimento sem pacote não pode usar as notificações do Windows. Instale o MSIX assinado para teste para verificar os lembretes.'; + + @override + String get windowsNotificationsInstalledFailure => + 'O BusyMax não conseguiu inicializar as notificações do Windows. Os lembretes não serão apresentados até este problema de instalação ser resolvido.'; + @override String get appearance => 'Aspeto'; @@ -1496,6 +4522,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get themeSystem => 'Sistema'; + @override + String get settingsSystem => 'Sistema'; + @override String get themeLight => 'Claro'; @@ -1574,7 +4603,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'tentativas=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'tentativas=$countString'; } @override @@ -1629,10 +4662,14 @@ class AppLocalizationsPt extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Há $count tarefas com prazo para hoje.', + other: 'Há $countString tarefas com prazo para hoje.', one: 'Há uma tarefa com prazo para hoje.', ); return '$_temp0'; @@ -1657,7 +4694,7 @@ class AppLocalizationsPt extends AppLocalizations { String get notificationSnoozeAction => 'Adiar 10 minutos'; @override - String get notificationDismissAction => 'Dispensar'; + String get notificationDismissAction => 'Fechar'; @override String get notificationDetailsHidden => @@ -1683,7 +4720,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'Semana $number'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Semana $numberString'; } @override @@ -1691,10 +4732,14 @@ class AppLocalizationsPt extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count itens', + other: '$countString itens', one: '1 item', ); return '$_temp0'; @@ -1710,17 +4755,17 @@ class AppLocalizationsPt extends AppLocalizations { String get searchLocations => 'Pesquisar locais'; @override - String get noLocationsFound => 'Nenhum local encontrado'; + String get noLocationsFound => 'Não foram encontrados locais'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'Este campo é obrigatório.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Ligue calendários e tarefas a um destes fornecedores.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Calendário Apple iCloud'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1729,198 +4774,224 @@ class AppLocalizationsPt extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Tarefas do Nextcloud'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => + 'Adicionar conta do Calendário Apple iCloud'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Adicionar conta Nextcloud'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'A ligar ao Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'A aguardar autorização do Nextcloud…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Ligar o Calendário Apple iCloud'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'E-mail da conta Apple'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => + 'Palavra-passe específica da aplicação'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Crie uma palavra-passe específica da aplicação depois de ativar a autenticação de dois fatores na sua conta Apple.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'A reposição da palavra-passe da conta Apple revoga as palavras-passe específicas de aplicações.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Ligar ao Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Servidor Nextcloud ou endereço CalDAV'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Introduza o URL do servidor Nextcloud ou cole o endereço CalDAV principal copiado do Nextcloud.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'O BusyMax abrirá o navegador. Aprove o acesso aí e volte ao BusyMax.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Ligar'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Cancelar ligação'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'A conta foi removida localmente, mas não foi possível revogar a palavra-passe da aplicação Nextcloud.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'Os dados de calendários e tarefas são colocados em cache localmente para utilização offline.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Volte a ligar esta conta para retomar a sincronização.'; @override String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + 'Esta conta está temporariamente indisponível.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'As permissões do servidor foram alteradas. As edições pendentes estão em pausa.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Este servidor ou perfil do fornecedor não é suportado.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Calendários e listas de tarefas'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'Eventos do calendário'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Tarefas'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Só de leitura'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'Pendente localmente'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Bloqueado por conflito'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'Bloqueado até voltar a ligar'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'Operação falhou'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Manter versão do servidor'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'Rever e reaplicar alteração local'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Duplicar como novo item'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'Estado da ligação'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Ligado'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'A ligar…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Sessão terminada'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Última sincronização bem-sucedida: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Ainda não sincronizado'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'Atualizar calendários e listas de tarefas'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Servidor: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Calendário de eventos'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Lista de tarefas'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'Eventos e tarefas'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Editável'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Partilhado'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Última sincronização: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Problema de sincronização: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Conflitos de sincronização'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Alteração no servidor: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Edição local: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => + 'Não foi possível resolver o conflito.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => 'Âmbito do evento recorrente'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => 'Série inteira'; + + @override + String get singleOccurrence => 'Este evento'; + + @override + String get thisAndFollowingEvents => 'Este evento e os seguintes'; + + @override + String get thisAndFutureUnavailable => 'Não suportado por este fornecedor.'; + + @override + String get thisAndFutureMoveUnavailable => + 'Este evento e os seguintes não podem ser movidos com segurança. Escolha este evento ou a série inteira.'; + + @override + String get entireSeriesMoveUnavailable => + 'A regra de recorrência não está disponível localmente. Mova apenas este evento.'; @override - String get singleOccurrence => 'This occurrence'; + String get copyEventAndDeleteOriginal => + 'Copiar o evento e eliminar o original?'; @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String copyEventMoveWarning(String source, String destination) { + return 'O BusyMax não pode mover este evento diretamente de $source para $destination. Criará primeiro a cópia e eliminará o original apenas depois de a cópia ser criada com êxito. Os IDs do evento mudarão; os estados das respostas dos participantes poderão ser repostos e poderão ser enviados convites ou cancelamentos; as ligações de reuniões, os anexos, os lembretes, os campos específicos do fornecedor e as exceções de recorrência poderão não ser transferidos.'; + } + + @override + String get copyAndDelete => 'Copiar e eliminar'; @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Escolha se esta alteração se aplica à série inteira, apenas a este evento ou a este e aos eventos seguintes.'; @override String get taskDueBeforeStart => 'O prazo não pode ser anterior ao início.'; @@ -1935,7 +5006,202 @@ class AppLocalizationsPt extends AppLocalizations { } @override - String get networkOffline => 'Offline'; + String get setCustomCalendarName => 'Definir nome personalizado'; + + @override + String get setAction => 'Definir'; + + @override + String get removeFromMyCalendars => 'Remover dos meus calendários'; + + @override + String get removeAction => 'Remover'; + + @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.'; + } + + @override + String get calendarCannotRemove => + 'Não é possível eliminar nem remover este calendário desta conta.'; + + @override + String get calendarPendingChangesPreventRemoval => + 'Aguarde que as alterações pendentes deste calendário terminem de sincronizar antes de o eliminar ou remover.'; + + @override + String get calendarSubscriptions => 'Subscrições de calendários'; + + @override + String get calendarSubscriptionsDescription => + 'Adicione calendários só de leitura que sejam atualizados a partir de um URL WebCal seguro.'; + + @override + String get addCalendarSubscription => 'Adicionar subscrição de calendário'; + + @override + String get subscriptionName => 'Nome local'; + + @override + String get subscriptionUrl => 'URL da subscrição'; + + @override + String get subscriptionUrlHelp => + 'Introduza um URL HTTPS ou webcal. O BusyMax mantém o URL completo num armazenamento seguro.'; + + @override + String get subscriptionUrlInvalid => + 'Introduza um URL HTTPS ou webcal válido sem informações de utilizador ou fragmento.'; + + @override + String get subscriptionColor => 'Cor local'; + + @override + String get subscriptionColorHelp => + 'Utilize uma cor de seis dígitos, como #3584E4.'; + + @override + String get subscriptionColorInvalid => + 'Introduza uma cor hexadecimal de seis dígitos.'; + + @override + String get subscriptionRefreshMode => 'Frequência de atualização'; + + @override + String get subscriptionAutomatic => 'Automática'; + + @override + String get subscriptionHourly => 'De hora a hora'; + + @override + String get subscriptionSixHours => 'A cada seis horas'; + + @override + String get subscriptionDaily => 'Diária'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Origem: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Introduza um URL válido para pré-visualizar a origem segura.'; + + @override + String get subscriptionReadOnly => 'Subscrição só de leitura'; + + @override + String get subscriptionNeverRefreshed => 'Ainda não atualizada'; + + @override + String subscriptionLastRefresh(String time) { + return 'Última atualização bem-sucedida: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Próxima atualização: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Atualizada'; + + @override + String subscriptionStatusIssue(String code) { + return 'Problema de atualização: $code'; + } + + @override + String get refreshNow => 'Atualizar agora'; + + @override + String get unsubscribe => 'Cancelar subscrição'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Cancelar subscrição de “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'Isto remove a subscrição local e os eventos colocados em cache. O calendário publicado não é alterado.'; + + @override + String get addSubscriptionAction => 'Adicionar subscrição'; + + @override + String subscriptionOperationFailed(String error) { + return 'Falha na subscrição do calendário: $error'; + } + + @override + String get subscriptions => 'Subscrições'; + + @override + String get calendarImport => 'Importação de calendário'; + + @override + String get calendarImportDescription => + 'Selecione um ficheiro, reveja os eventos e escolha o calendário editável que os deve receber.'; + + @override + String get importIcsFile => 'Importar ficheiro .ics'; + + @override + String get importIcsPreview => 'Importar eventos do calendário'; + + @override + String importEventsFound(int count) { + return 'Conjuntos de eventos importáveis: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Eventos inválidos: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Omitidos intencionalmente: $fields'; + } + + @override + String get noWritableCalendars => + 'Não está disponível nenhum calendário de destino editável.'; + + @override + String get importDestinationCalendar => 'Calendário de destino'; + + @override + String get importIcsConfirm => 'Importar eventos'; + + @override + String get importIcsComplete => 'Importação concluída'; + + @override + String importQueued(int count) { + return 'Importados ou em fila: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicados ignorados: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Conjuntos de recorrência não suportados: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Não foi possível importar o ficheiro de calendário: $error'; + } + + @override + String get networkOffline => 'Sem ligação'; @override String get networkOfflineDescription => @@ -1944,4 +5210,139 @@ class AppLocalizationsPt extends AppLocalizations { @override String get networkOfflineTryAgain => 'Está offline. Ligue-se à Internet e tente novamente.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'nos dias $days'; + } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(String day) { + return '$day'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first e $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first e $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first e $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first e $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency no dia $day de $month'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency nos dias $days de $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency no dia $day de $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency nos dias $days de $months'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'na primeira ocorrência de $days de $month', + 'second': 'na segunda ocorrência de $days de $month', + 'third': 'na terceira ocorrência de $days de $month', + 'fourth': 'na quarta ocorrência de $days de $month', + 'fifth': 'na quinta ocorrência de $days de $month', + 'secondToLast': 'na penúltima ocorrência de $days de $month', + 'last': 'na última ocorrência de $days de $month', + 'other': 'em $days de $month', + }); + return '$frequency $_temp0'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'na primeira ocorrência de $days de $months', + 'second': 'na segunda ocorrência de $days de $months', + 'third': 'na terceira ocorrência de $days de $months', + 'fourth': 'na quarta ocorrência de $days de $months', + 'fifth': 'na quinta ocorrência de $days de $months', + 'secondToLast': 'na penúltima ocorrência de $days de $months', + 'last': 'na última ocorrência de $days de $months', + 'other': 'em $days de $months', + }); + return '$frequency $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 90697731..22ecd413 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -12,12 +12,109 @@ import 'app_localizations.dart'; class AppLocalizationsRu extends AppLocalizations { AppLocalizationsRu([String locale = 'ru']) : super(locale); + @override + String get windowsSupport => 'Поддержка'; + + @override + String get windowsThirdPartyLicenses => 'Лицензии сторонних компонентов'; + + @override + String get windowsSearch => 'Поиск'; + + @override + String get windowsStartupDisabledByUser => + 'Отключено пользователем в параметрах Windows.'; + + @override + String get windowsStartupDisabledByPolicy => 'Отключено политикой Windows.'; + + @override + String get windowsStartupUnavailable => + 'Доступно после установки BusyMax из пакета MSIX.'; + + @override + String get windowsReminderExitNotice => + 'Напоминания прекращаются после полного выхода из BusyMax. Оставьте приложение работать в фоновом режиме, чтобы получать их.'; + + @override + String get windowsProductVersionLabel => 'Версия продукта'; + + @override + String get windowsPackageVersionLabel => 'Версия пакета Windows'; + + @override + String get windowsUnpackaged => 'Без пакета'; + + @override + String get windowsAgendaLoadMore => 'Загрузить ещё элементы повестки'; + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'понедельникам', + 'TU': 'вторникам', + 'WE': 'средам', + 'TH': 'четвергам', + 'FR': 'пятницам', + 'SA': 'субботам', + 'SU': 'воскресеньям', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return '$first и $second числа каждого месяца'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency: $month, $firstDay и $secondDay числа'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency: $firstMonth и $secondMonth, $day числа'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency: $firstMonth и $secondMonth, $firstDay и $secondDay числа'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency: $firstMonth и $secondMonth, $days числа'; + } + @override String get appTitle => 'BusyMax'; @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Подключите аккаунты Google, Microsoft, Apple iCloud Calendar или Nextcloud.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +138,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Добавьте все нужные аккаунты. BusyMax синхронизирует поддерживаемые календари, события, списки задач и задачи из каждого аккаунта.'; @override String get onboardingPreferencesStepTitle => 'Выберите системные параметры'; @@ -117,6 +214,44 @@ class AppLocalizationsRu extends AppLocalizations { @override String get calendars => 'Календари'; + @override + String get newCalendar => 'Новый календарь'; + + @override + String get calendarColor => 'Цвет календаря'; + + @override + String calendarColorOption(int number) { + return 'Цвет $number'; + } + + @override + String get calendarManagementUnsupported => + 'Этот провайдер не поддерживает управление календарями в BusyMax.'; + + @override + String get primaryCalendarCannotDelete => + 'Основной календарь нельзя удалить.'; + + @override + String calendarCreateFailed(String error) { + return 'Не удалось создать календарь: $error'; + } + + @override + String get calendarCreatedRefreshPending => + 'Календарь создан, но BusyMax не смог обновить учётную запись. Он появится после следующей синхронизации.'; + + @override + String calendarUpdateFailed(String error) { + return 'Не удалось обновить календарь: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Не удалось удалить календарь: $error'; + } + @override String get newEvent => 'Новое событие'; @@ -140,7 +275,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String moreItems(int count) { - return '+ ещё $count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+ ещё $countString'; } @override @@ -179,6 +318,109 @@ class AppLocalizationsRu extends AppLocalizations { @override String get trayOpenBusyMax => 'Открыть BusyMax'; + @override + String get trayShowBusyMax => 'Показать BusyMax'; + + @override + String get trayNewEvent => 'Новое событие…'; + + @override + String get trayNewTask => 'Новая задача…'; + + @override + String get trayToday => 'Сегодня'; + + @override + String get trayAllDay => 'Весь день'; + + @override + String get trayNow => 'Сейчас'; + + @override + String get trayCalendarEvent => 'Событие календаря'; + + @override + String get trayUntitledEvent => 'Событие без названия'; + + @override + String get trayNothingElseToday => 'На сегодня больше ничего'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Сегодня истекает срок $count задачи', + many: 'Сегодня истекает срок $count задач', + few: 'Сегодня истекает срок $count задач', + one: 'Сегодня истекает срок 1 задачи', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Открыть расписание на сегодня'; + + @override + String get traySyncNow => 'Синхронизировать сейчас'; + + @override + String get traySyncing => 'Синхронизация…'; + + @override + String get trayNotConnected => 'Не подключено'; + + @override + String get trayNotYetSynced => 'Ещё не синхронизировано'; + + @override + String get trayLastSyncedJustNow => 'Синхронизировано только что'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Синхронизировано $count минуты назад', + many: 'Синхронизировано $count минут назад', + few: 'Синхронизировано $count минуты назад', + one: 'Синхронизировано минуту назад', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Синхронизировано $count часа назад', + many: 'Синхронизировано $count часов назад', + few: 'Синхронизировано $count часа назад', + one: 'Синхронизировано час назад', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Синхронизировано $count дня назад', + many: 'Синхронизировано $count дней назад', + few: 'Синхронизировано $count дня назад', + one: 'Синхронизировано день назад', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Настройки'; + + @override + String get trayQuitBusyMax => 'Выйти из BusyMax'; + @override String get agendaLoadMoreOverdue => 'Загрузить ещё просроченные задачи'; @@ -262,127 +504,126 @@ class AppLocalizationsRu extends AppLocalizations { String get noGuests => 'Нет гостей'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Обязательно'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Необязательно'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Встреча'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Добавить Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Добавить встречу Microsoft Teams'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Онлайн-встреча добавлена'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Запрашивать ответы'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'Попросите гостей ответить на приглашение.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Скрыть список гостей'; @override - String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + String get hideGuestListDescription => 'Гости не видят других приглашённых.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'Разрешить новые предложения времени'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Гости могут предложить другое время встречи.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'Уведомить гостей?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'На встрече есть гости. Отправить приглашения или обновления события при сохранении?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'На встрече есть гости. Отправить отмену при удалении?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Отправить обновления'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Отправить отмену'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'Не отправлять'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'Сохранить встречу?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft отправит гостям приглашения или обновления события.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'Удалить встречу?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft отправит гостям отмену.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Организатор'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'Ваш ответ'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Ответы гостей'; @override - String get respond => 'Respond'; + String get respond => 'Ответить'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Принять'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Под вопросом'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Отклонить'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'Присоединиться к встрече'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Принято'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Под вопросом'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Отклонено'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'Ожидается ответ'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Нет ответа'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Организатор'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'Не удалось отправить ответ: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'Не удалось открыть ссылку на встречу.'; @override String get description => 'Описание'; @@ -428,13 +669,17 @@ class AppLocalizationsRu extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: 'За $minutes минуты', - many: 'За $minutes минут', - few: 'За $minutes минуты', - one: 'За $minutes минуту', + other: 'За $minutesString минуты', + many: 'За $minutesString минут', + few: 'За $minutesString минуты', + one: 'За $minutesString минуту', ); return '$_temp0'; } @@ -444,26 +689,35 @@ class AppLocalizationsRu extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: 'За $hours часа', - many: 'За $hours часов', - few: 'За $hours часа', - one: 'За $hours час', + other: 'За $hoursString часа', + many: 'За $hoursString часов', + few: 'За $hoursString часа', + one: 'За $hoursString час', ); return '$_temp0'; } @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: 'За $days дня', - many: 'За $days дней', - few: 'За $days дня', - one: 'За $days день', + other: 'За $daysString дня', + many: 'За $daysString дней', + few: 'За $daysString дня', + one: 'За $daysString день', ); return '$_temp0'; } @@ -649,7 +903,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Будут добавлены только версия Linux и выбранный язык приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не добавляются.'; + 'Будут добавлены только название и версия операционной системы и выбранный язык приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не добавляются.'; @override String get feedbackCategoryRequired => 'Выберите категорию.'; @@ -741,7 +995,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'С устройства будут удалены кэшированные задачи, календари, события, напоминания и ожидающие автономные изменения. Несинхронизированные изменения будут потеряны. Копии календарей, событий, списков задач и задач у поставщика не удаляются.'; @override String get revokeGoogleAccess => @@ -767,17 +1021,17 @@ class AppLocalizationsRu extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'Не удалось создать список задач: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'Не удалось переименовать список задач: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'Не удалось удалить список задач: $error'; } @override @@ -803,15 +1057,15 @@ class AppLocalizationsRu extends AppLocalizations { String get deleteList => 'Удалить список'; @override - String get unshare => 'Закрыть доступ'; + String get unshare => 'Отменить общий доступ'; @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'Этот список задач доступен только для чтения и не может быть переименован.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'Этот список задач нельзя удалить с текущими разрешениями.'; @override String get builtInMicrosoftList => 'Встроенный'; @@ -827,12 +1081,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return 'Удалить «$title» и все его задачи?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'Отменить общий доступ к «$title» для этого аккаунта?'; } @override @@ -957,75 +1211,93 @@ class AppLocalizationsRu extends AppLocalizations { String get openStatus => 'Открыта'; @override - String get doneStatus => 'Выполнена'; + String get doneStatus => 'Выполнено'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'Статус'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Нет статуса'; @override String get taskStatusNeedsAction => 'Требуется действие'; @override - String get taskStatusInProcess => 'Выполянется'; + String get taskStatusInProcess => 'Выполняется'; @override - String get taskStatusCompleted => 'Завершённые'; + String get taskStatusCompleted => 'Завершена'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Отменена'; @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return 'Выполнено на $percentString%'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Дата выполнения'; @override String get priority => 'Приоритет'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Без приоритета'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Приоритет $priorityString · высокий'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Приоритет $priorityString · средний'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Приоритет $priorityString · низкий'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'URL задачи'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'Введите абсолютный URL вместе со схемой.'; @override - String get classification => 'Classification'; + String get classification => 'Классификация'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => + 'При общем доступе показывать задачу полностью'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => + 'При общем доступе показывать только занятость'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'При общем доступе скрывать эту задачу'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Закрепить задачу'; @override String get notes => 'Заметки'; @@ -1064,13 +1336,13 @@ class AppLocalizationsRu extends AppLocalizations { String get addReminder => 'Добавить напоминание'; @override - String get reminders => 'Reminders'; + String get reminders => 'Напоминания'; @override - String get noReminders => 'Напоминаний нет'; + String get noReminders => 'Нет напоминаний'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Изменить напоминание'; @override String get beforeTaskStarts => 'До начала задачи'; @@ -1079,64 +1351,68 @@ class AppLocalizationsRu extends AppLocalizations { String get beforeTaskDue => 'До срока выполнения задачи'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'После начала задачи'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'После срока выполнения задачи'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'Относительно даты начала задачи'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'Относительно срока выполнения задачи'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Время суток'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'В указанную дату и время'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Количество'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Единица'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Секунды'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'Минуты'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Часы'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Дни'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Недели'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'В момент начала задачи'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'В момент наступления срока задачи'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'Тип этого напоминания сохраняется, но его время нельзя изменить.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'Сохранить связанные напоминания?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'На эту дату приходится $countString связанных напоминаний. Сохранить их текущие дату и время?'; } @override - String get discardRelatedReminders => 'Отменить напоминания'; + String get discardRelatedReminders => 'Удалить напоминания'; @override String get keepRelatedReminders => 'Сохранить напоминания'; @@ -1175,111 +1451,167 @@ class AppLocalizationsRu extends AppLocalizations { String get repeatYearly => 'Ежегодно'; @override - String get repeatEvery => 'Повторять каждые'; + String get repeatEvery => 'Интервал'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'Повторять в'; @override - String get repeatEnd => 'Прекратить повтор'; + String get repeatEnd => 'Завершить повтор'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'Никогда'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'В указанную дату'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'После указанного числа повторений'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'Число повторений'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'Дни месяца'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'Месяцы'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'Порядок дня недели'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'Определённые дни'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'Первый'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'Второй'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'Третий'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'Четвёртый'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'Пятый'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'Предпоследний'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'Последний'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'День'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'Будний день'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'Выходной день'; + + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'понедельник', + 'TU': 'вторник', + 'WE': 'среду', + 'TH': 'четверг', + 'FR': 'пятницу', + 'SA': 'субботу', + 'SU': 'воскресенье', + 'day': 'день', + 'weekday': 'будний день', + 'weekend': 'выходной день', + 'other': '$day', + }); + return '$_temp0'; + } @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Каждые $countString дн.'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Каждые $countString нед.'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Каждые $countString мес.'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Каждые $countString г.'; } @override String repeatOnDaysSummary(String days) { - return 'в $days'; + return 'по $days'; } @override String repeatOnMonthDaysSummary(String days) { - return ' $days'; + return '$days числа месяца'; } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'в первый $days', + 'second': 'во второй $days', + 'third': 'в третий $days', + 'fourth': 'в четвёртый $days', + 'fifth': 'в пятый $days', + 'secondToLast': 'в предпоследний $days', + 'last': 'в последний $days', + 'other': 'в $days', + }); + return '$_temp0'; } @override String repeatInMonthsSummary(String months) { - return 'в $months'; + return 'в месяцы $months'; } @override String repeatTimesSummary(int count) { - return '$count раз'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString раза', + many: '$countString раз', + few: '$countString раза', + one: '$countString раз', + ); + return '$_temp0'; } @override @@ -1289,7 +1621,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'Это правило повторения использует параметры, которые этот редактор не изменяет.'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'Это повторение нельзя использовать с $provider.'; + } @override String get importance => 'Важность'; @@ -1347,15 +1684,15 @@ class AppLocalizationsRu extends AppLocalizations { String get duplicateTask => 'Дублировать задачу'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Задача продублирована.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'Не удалось продублировать задачу: $error'; } @override - String get hideSubtasks => 'Скрыть вложенные задачи'; + String get hideSubtasks => 'Скрыть подзадачи'; @override String get hideClosedSubtasks => 'Скрыть закрытые подзадачи'; @@ -1414,7 +1751,11 @@ class AppLocalizationsRu extends AppLocalizations { String get sync => 'Синхронизация'; @override - String get manualFullSync => 'Полная синхронизация вручную'; + String get forceFullResync => 'Принудительная полная синхронизация'; + + @override + String get forceFullResyncDescription => + 'Полностью загружает заново данные всех подключённых аккаунтов. Используйте эту функцию только для устранения проблем с синхронизацией.'; @override String get runInBackgroundWhenClosed => @@ -1461,6 +1802,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get eventReminders => 'Напоминания о событиях'; + @override + String get onState => 'Вкл.'; + @override String get taskReminders => 'Напоминания о задачах'; @@ -1488,6 +1832,18 @@ class AppLocalizationsRu extends AppLocalizations { @override String get notifications => 'Уведомления'; + @override + String get windowsNotificationsUnavailable => + 'Уведомления Windows недоступны'; + + @override + String get windowsNotificationsUnpackaged => + 'Эта незапакованная сборка для разработки не может использовать уведомления Windows. Для проверки напоминаний установите тестово подписанный MSIX.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax не удалось инициализировать уведомления Windows. Напоминания не будут отображаться, пока проблема установки не будет устранена.'; + @override String get appearance => 'Внешний вид'; @@ -1497,6 +1853,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get themeSystem => 'Системная'; + @override + String get settingsSystem => 'Система'; + @override String get themeLight => 'Светлая'; @@ -1574,7 +1933,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'попытки=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'попытки=$countString'; } @override @@ -1619,7 +1982,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return 'Локальное изменение не удалось синхронизировать. $summary'; + return 'Ожидающее локальное изменение заблокировано. $summary'; } @override @@ -1627,13 +1990,17 @@ class AppLocalizationsRu extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Сегодня нужно выполнить $count задачи.', - many: 'Сегодня нужно выполнить $count задач.', - few: 'Сегодня нужно выполнить $count задачи.', - one: 'Сегодня нужно выполнить $count задачу.', + other: 'Сегодня нужно выполнить $countString задачи.', + many: 'Сегодня нужно выполнить $countString задач.', + few: 'Сегодня нужно выполнить $countString задачи.', + one: 'Сегодня нужно выполнить $countString задачу.', ); return '$_temp0'; } @@ -1658,7 +2025,7 @@ class AppLocalizationsRu extends AppLocalizations { String get notificationSnoozeAction => 'Отложить на 10 минут'; @override - String get notificationDismissAction => 'Отклонить'; + String get notificationDismissAction => 'Закрыть'; @override String get notificationDetailsHidden => @@ -1684,7 +2051,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'Неделя $number'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Неделя $numberString'; } @override @@ -1692,13 +2063,17 @@ class AppLocalizationsRu extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count записи', - many: '$count записей', - few: '$count записи', - one: '$count запись', + other: '$countString записи', + many: '$countString записей', + few: '$countString записи', + one: '$countString запись', ); return '$_temp0'; } @@ -1710,20 +2085,20 @@ class AppLocalizationsRu extends AppLocalizations { String get selectTimeZone => 'Выберите часовой пояс'; @override - String get searchLocations => 'Поиск города'; + String get searchLocations => 'Поиск мест'; @override - String get noLocationsFound => 'Ничего не найдено'; + String get noLocationsFound => 'Места не найдены'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'Это поле обязательно.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Подключите календари и задачи одного из следующих поставщиков.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Календарь Apple iCloud'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1732,198 +2107,221 @@ class AppLocalizationsRu extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Задачи Nextcloud'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'Добавить аккаунт Календаря Apple iCloud'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Добавить аккаунт Nextcloud'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Подключение к Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Ожидание авторизации Nextcloud…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Подключить Календарь Apple iCloud'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Электронная почта аккаунта Apple'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'Пароль для приложения'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Создайте пароль для приложения после включения двухфакторной аутентификации аккаунта Apple.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Сброс пароля аккаунта Apple отзывает пароли для приложений.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Подключить Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Сервер Nextcloud или адрес CalDAV'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Введите URL сервера Nextcloud или вставьте основной адрес CalDAV, скопированный из Nextcloud.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax откроет браузер. Разрешите доступ и вернитесь в BusyMax.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Подключить'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Отменить подключение'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'Аккаунт удалён локально, но пароль приложения Nextcloud не удалось отозвать.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'Данные календаря и задач кэшируются локально для работы без подключения.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Подключите аккаунт заново, чтобы возобновить синхронизацию.'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get davTemporarilyUnavailable => 'Этот аккаунт временно недоступен.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'Разрешения сервера изменились. Ожидающие изменения приостановлены.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Этот сервер или профиль поставщика не поддерживается.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Календари и списки задач'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'События календаря'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Задачи'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Только чтение'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'Ожидает локально'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Заблокировано из-за конфликта'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'Заблокировано до повторного подключения'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'Операция не выполнена'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Сохранить версию сервера'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => + 'Проверить и повторно применить локальное изменение'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Дублировать как новый элемент'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'Состояние подключения'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Подключено'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'Подключение…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Выполнен выход'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Последняя успешная синхронизация: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Ещё не синхронизировано'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'Обновить календари и списки задач'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Сервер: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Календарь событий'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Список задач'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'События и задачи'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Доступно для записи'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Общий доступ'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Последняя синхронизация: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Проблема синхронизации: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Конфликты синхронизации'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Изменено на сервере: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Локальное изменение: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'Не удалось разрешить конфликт.'; + + @override + String get recurringEventScope => 'Область повторяющегося события'; + + @override + String get entireSeries => 'Вся серия'; + + @override + String get singleOccurrence => 'Это событие'; @override - String get recurringEventScope => 'Recurring event scope'; + String get thisAndFollowingEvents => 'Это и последующие события'; @override - String get entireSeries => 'Entire series'; + String get thisAndFutureUnavailable => 'Не поддерживается этим поставщиком.'; @override - String get singleOccurrence => 'This occurrence'; + String get thisAndFutureMoveUnavailable => + 'Это и последующие события нельзя безопасно переместить. Выберите это событие или всю серию.'; @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get entireSeriesMoveUnavailable => + 'Правило повтора недоступно локально. Переместите только это событие.'; + + @override + String get copyEventAndDeleteOriginal => + 'Скопировать событие и удалить оригинал?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax не может напрямую переместить это событие из $source в $destination. Сначала будет создана копия, а оригинал будет удалён только после успешного копирования. Идентификаторы события изменятся; статусы ответов участников могут быть сброшены, а приглашения или отмены — отправлены; ссылки на конференции, вложения, напоминания, поля поставщика и исключения повтора могут не сохраниться.'; + } + + @override + String get copyAndDelete => 'Скопировать и удалить'; @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Выберите, применять ли это изменение ко всей серии, только к этому событию или к этому и последующим событиям.'; @override String get taskDueBeforeStart => 'Срок не может быть раньше начала.'; @@ -1937,6 +2335,201 @@ class AppLocalizationsRu extends AppLocalizations { return 'Удалить «$title»?'; } + @override + String get setCustomCalendarName => 'Задать своё название'; + + @override + String get setAction => 'Задать'; + + @override + String get removeFromMyCalendars => 'Удалить из моих календарей'; + + @override + String get removeAction => 'Удалить'; + + @override + String removeCalendarConfirmation(String title) { + return 'Удалить «$title» из вашего списка Google Календаря? Общий календарь и его события удалены не будут.'; + } + + @override + String get calendarCannotRemove => + 'Этот календарь нельзя удалить или убрать из аккаунта.'; + + @override + String get calendarPendingChangesPreventRemoval => + 'Дождитесь синхронизации ожидающих изменений этого календаря перед удалением или отключением.'; + + @override + String get calendarSubscriptions => 'Подписки на календари'; + + @override + String get calendarSubscriptionsDescription => + 'Добавляйте календари только для чтения, обновляемые по защищённому URL WebCal.'; + + @override + String get addCalendarSubscription => 'Добавить подписку на календарь'; + + @override + String get subscriptionName => 'Локальное имя'; + + @override + String get subscriptionUrl => 'URL подписки'; + + @override + String get subscriptionUrlHelp => + 'Введите HTTPS- или webcal-URL. BusyMax хранит полный URL в защищённом хранилище.'; + + @override + String get subscriptionUrlInvalid => + 'Введите действительный HTTPS- или webcal-URL без данных пользователя или фрагмента.'; + + @override + String get subscriptionColor => 'Локальный цвет'; + + @override + String get subscriptionColorHelp => + 'Используйте шестизначный цвет, например #3584E4.'; + + @override + String get subscriptionColorInvalid => + 'Введите шестизначный шестнадцатеричный цвет.'; + + @override + String get subscriptionRefreshMode => 'Частота обновления'; + + @override + String get subscriptionAutomatic => 'Автоматически'; + + @override + String get subscriptionHourly => 'Ежечасно'; + + @override + String get subscriptionSixHours => 'Каждые шесть часов'; + + @override + String get subscriptionDaily => 'Ежедневно'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Источник: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Введите действительный URL для просмотра безопасного источника.'; + + @override + String get subscriptionReadOnly => 'Подписка только для чтения'; + + @override + String get subscriptionNeverRefreshed => 'Ещё не обновлялось'; + + @override + String subscriptionLastRefresh(String time) { + return 'Последнее успешное обновление: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Следующее обновление: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Актуально'; + + @override + String subscriptionStatusIssue(String code) { + return 'Проблема обновления: $code'; + } + + @override + String get refreshNow => 'Обновить сейчас'; + + @override + String get unsubscribe => 'Отменить подписку'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Отменить подписку на «$name»?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'Локальная подписка и кэшированные события будут удалены. Опубликованный календарь не изменится.'; + + @override + String get addSubscriptionAction => 'Добавить подписку'; + + @override + String subscriptionOperationFailed(String error) { + return 'Ошибка подписки на календарь: $error'; + } + + @override + String get subscriptions => 'Подписки'; + + @override + String get calendarImport => 'Импорт календаря'; + + @override + String get calendarImportDescription => + 'Выберите файл, просмотрите его события, а затем выберите доступный для записи календарь, который должен их принять.'; + + @override + String get importIcsFile => 'Импортировать файл .ics'; + + @override + String get importIcsPreview => 'Импортировать события календаря'; + + @override + String importEventsFound(int count) { + return 'Наборы импортируемых событий: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Недопустимые события: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Намеренно пропущено: $fields'; + } + + @override + String get noWritableCalendars => + 'Нет доступного календаря назначения с правом записи.'; + + @override + String get importDestinationCalendar => 'Календарь назначения'; + + @override + String get importIcsConfirm => 'Импортировать события'; + + @override + String get importIcsComplete => 'Импорт завершён'; + + @override + String importQueued(int count) { + return 'Импортировано или поставлено в очередь: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Пропущено дубликатов: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Неподдерживаемые наборы повторений: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Не удалось импортировать файл календаря: $error'; + } + @override String get networkOffline => 'Нет подключения'; @@ -1947,4 +2540,139 @@ class AppLocalizationsRu extends AppLocalizations { @override String get networkOfflineTryAgain => 'Нет подключения к сети. Подключитесь к Интернету и повторите попытку.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'в дни месяца $days'; + } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(String day) { + return '$day-го'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first и $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first и $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first и $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first и $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency: $month, $day числа'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency: $month, $days числа'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency: $months, $day числа'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency: $months, $days числа'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'в первый $days', + 'second': 'во второй $days', + 'third': 'в третий $days', + 'fourth': 'в четвёртый $days', + 'fifth': 'в пятый $days', + 'secondToLast': 'в предпоследний $days', + 'last': 'в последний $days', + 'other': 'в $days', + }); + return '$frequency: $month, $_temp0'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'в первый $days', + 'second': 'во второй $days', + 'third': 'в третий $days', + 'fourth': 'в четвёртый $days', + 'fifth': 'в пятый $days', + 'secondToLast': 'в предпоследний $days', + 'last': 'в последний $days', + 'other': 'в $days', + }); + return '$frequency: $months, $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 970835be..1468dbee 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -12,12 +12,110 @@ import 'app_localizations.dart'; class AppLocalizationsVi extends AppLocalizations { AppLocalizationsVi([String locale = 'vi']) : super(locale); + @override + String get windowsSupport => 'Hỗ trợ'; + + @override + String get windowsThirdPartyLicenses => 'Giấy phép bên thứ ba'; + + @override + String get windowsSearch => 'Tìm kiếm'; + + @override + String get windowsStartupDisabledByUser => + 'Người dùng đã tắt trong Cài đặt Windows.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Đã tắt theo chính sách Windows.'; + + @override + String get windowsStartupUnavailable => + 'Khả dụng sau khi cài đặt BusyMax từ gói MSIX.'; + + @override + String get windowsReminderExitNotice => + 'Lời nhắc sẽ dừng khi thoát hẳn BusyMax. Hãy để ứng dụng chạy trong nền để nhận lời nhắc.'; + + @override + String get windowsProductVersionLabel => 'Phiên bản sản phẩm'; + + @override + String get windowsPackageVersionLabel => 'Phiên bản gói Windows'; + + @override + String get windowsUnpackaged => 'Chưa đóng gói'; + + @override + String get windowsAgendaLoadMore => 'Tải thêm mục lịch biểu'; + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'Thứ Hai', + 'TU': 'Thứ Ba', + 'WE': 'Thứ Tư', + 'TH': 'Thứ Năm', + 'FR': 'Thứ Sáu', + 'SA': 'Thứ Bảy', + 'SU': 'Chủ Nhật', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'vào các ngày $first và $second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency vào các ngày $firstDay và $secondDay của $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency vào ngày $day của $firstMonth và $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency vào các ngày $firstDay và $secondDay của $firstMonth và $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency vào các ngày $days của $firstMonth và $secondMonth'; + } + @override String get appTitle => 'BusyMax'; @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Kết nối tài khoản Google, Microsoft, Apple iCloud Calendar hoặc Nextcloud.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +139,7 @@ class AppLocalizationsVi extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Thêm mọi tài khoản bạn muốn sử dụng. BusyMax đồng bộ lịch, sự kiện, danh sách công việc và công việc được hỗ trợ từ từng tài khoản.'; @override String get onboardingPreferencesStepTitle => 'Chọn cài đặt hệ thống'; @@ -117,6 +215,43 @@ class AppLocalizationsVi extends AppLocalizations { @override String get calendars => 'Lịch'; + @override + String get newCalendar => 'Lịch mới'; + + @override + String get calendarColor => 'Màu lịch'; + + @override + String calendarColorOption(int number) { + return 'Màu $number'; + } + + @override + String get calendarManagementUnsupported => + 'Nhà cung cấp này không hỗ trợ quản lý lịch trong BusyMax.'; + + @override + String get primaryCalendarCannotDelete => 'Không thể xóa lịch chính.'; + + @override + String calendarCreateFailed(String error) { + return 'Không thể tạo lịch: $error'; + } + + @override + String get calendarCreatedRefreshPending => + 'Lịch đã được tạo nhưng BusyMax không thể làm mới tài khoản. Lịch sẽ xuất hiện sau lần đồng bộ tiếp theo.'; + + @override + String calendarUpdateFailed(String error) { + return 'Không thể cập nhật lịch: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Không thể xóa lịch: $error'; + } + @override String get newEvent => 'Sự kiện mới'; @@ -140,7 +275,11 @@ class AppLocalizationsVi extends AppLocalizations { @override String moreItems(int count) { - return '+$count mục khác'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString mục khác'; } @override @@ -181,6 +320,101 @@ class AppLocalizationsVi extends AppLocalizations { @override String get trayOpenBusyMax => 'Mở BusyMax'; + @override + String get trayShowBusyMax => 'Hiển thị BusyMax'; + + @override + String get trayNewEvent => 'Sự kiện mới…'; + + @override + String get trayNewTask => 'Công việc mới…'; + + @override + String get trayToday => 'Hôm nay'; + + @override + String get trayAllDay => 'Cả ngày'; + + @override + String get trayNow => 'Bây giờ'; + + @override + String get trayCalendarEvent => 'Sự kiện lịch'; + + @override + String get trayUntitledEvent => 'Sự kiện chưa có tiêu đề'; + + @override + String get trayNothingElseToday => 'Hôm nay không còn gì khác'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count công việc đến hạn hôm nay', + one: '1 công việc đến hạn hôm nay', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Mở lịch hôm nay'; + + @override + String get traySyncNow => 'Đồng bộ ngay'; + + @override + String get traySyncing => 'Đang đồng bộ…'; + + @override + String get trayNotConnected => 'Chưa kết nối'; + + @override + String get trayNotYetSynced => 'Chưa đồng bộ'; + + @override + String get trayLastSyncedJustNow => 'Vừa đồng bộ'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Đồng bộ $count phút trước', + one: 'Đồng bộ 1 phút trước', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Đồng bộ $count giờ trước', + one: 'Đồng bộ 1 giờ trước', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Đồng bộ $count ngày trước', + one: 'Đồng bộ 1 ngày trước', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Cài đặt'; + + @override + String get trayQuitBusyMax => 'Thoát BusyMax'; + @override String get agendaLoadMoreOverdue => 'Tải thêm công việc quá hạn'; @@ -261,130 +495,129 @@ class AppLocalizationsVi extends AppLocalizations { String get guests => 'Khách mời'; @override - String get noGuests => 'Không có khách mời'; + String get noGuests => 'Không có khách'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Bắt buộc'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Tùy chọn'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Cuộc họp'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Thêm Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Thêm cuộc họp Microsoft Teams'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Đã thêm cuộc họp trực tuyến'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Yêu cầu phản hồi'; @override - String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + String get requestResponsesDescription => 'Yêu cầu khách trả lời lời mời.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Ẩn danh sách khách'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'Khách không thể xem những người khác được mời.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'Cho phép đề xuất thời gian mới'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Khách có thể đề xuất thời gian họp khác.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'Thông báo cho khách?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'Cuộc họp này có khách. Gửi lời mời hoặc cập nhật sự kiện khi lưu?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'Cuộc họp này có khách. Gửi thông báo hủy khi xóa?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Gửi cập nhật'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Gửi thông báo hủy'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'Không gửi'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'Lưu cuộc họp?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft sẽ gửi lời mời hoặc cập nhật sự kiện cho khách.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'Xóa cuộc họp?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft sẽ gửi thông báo hủy cho khách.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Người tổ chức'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'Phản hồi của bạn'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Phản hồi của khách'; @override - String get respond => 'Respond'; + String get respond => 'Phản hồi'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Chấp nhận'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Tạm thời'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Từ chối'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'Tham gia cuộc họp'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Đã chấp nhận'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Tạm thời'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Đã từ chối'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'Đang chờ phản hồi'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Chưa phản hồi'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Người tổ chức'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'Không thể gửi phản hồi: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'Không thể mở liên kết cuộc họp.'; @override String get description => 'Mô tả'; @@ -430,10 +663,14 @@ class AppLocalizationsVi extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: 'Trước $minutes phút', + other: 'Trước $minutesString phút', one: 'Trước 1 phút', ); return '$_temp0'; @@ -444,10 +681,14 @@ class AppLocalizationsVi extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: 'Trước $hours giờ', + other: 'Trước $hoursString giờ', one: 'Trước 1 giờ', ); return '$_temp0'; @@ -455,10 +696,15 @@ class AppLocalizationsVi extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: 'Trước $days ngày', + other: 'Trước $daysString ngày', one: 'Trước 1 ngày', ); return '$_temp0'; @@ -645,7 +891,7 @@ class AppLocalizationsVi extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Chỉ thêm phiên bản hệ điều hành Linux và ngôn ngữ, khu vực của ứng dụng. Không bao gồm nhật ký, dữ liệu tài khoản, tên tệp hoặc thông tin chẩn đoán khác.'; + 'Chỉ thêm tên và phiên bản hệ điều hành cùng ngôn ngữ, khu vực của ứng dụng. Không bao gồm nhật ký, dữ liệu tài khoản, tên tệp hoặc thông tin chẩn đoán khác.'; @override String get feedbackCategoryRequired => 'Hãy chọn một danh mục.'; @@ -735,7 +981,7 @@ class AppLocalizationsVi extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'Thao tác này xóa khỏi thiết bị các công việc, lịch, sự kiện, lời nhắc và thay đổi ngoại tuyến đang chờ được lưu trong bộ nhớ đệm. Các thay đổi chưa đồng bộ sẽ mất. Bản sao lịch, sự kiện, danh sách công việc và công việc ở nhà cung cấp không bị xóa.'; @override String get revokeGoogleAccess => @@ -761,17 +1007,17 @@ class AppLocalizationsVi extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'Không thể tạo danh sách công việc: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'Không thể đổi tên danh sách công việc: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'Không thể xóa danh sách công việc: $error'; } @override @@ -797,15 +1043,15 @@ class AppLocalizationsVi extends AppLocalizations { String get deleteList => 'Xóa danh sách'; @override - String get unshare => 'Bỏ chia sẽ'; + String get unshare => 'Ngừng chia sẻ'; @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'Danh sách công việc này ở chế độ chỉ đọc và không thể đổi tên.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'Bạn không thể xóa danh sách công việc này với quyền hiện tại.'; @override String get builtInMicrosoftList => 'Tích hợp sẵn'; @@ -821,12 +1067,12 @@ class AppLocalizationsVi extends AppLocalizations { @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return 'Xóa “$title” và tất cả công việc trong đó?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'Ngừng chia sẻ “$title” khỏi tài khoản này?'; } @override @@ -951,75 +1197,92 @@ class AppLocalizationsVi extends AppLocalizations { String get openStatus => 'Chưa hoàn thành'; @override - String get doneStatus => 'Đã hoàn thành'; + String get doneStatus => 'Đã xong'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'Trạng thái'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Không có trạng thái'; @override - String get taskStatusNeedsAction => 'Needs action'; + String get taskStatusNeedsAction => 'Cần xử lý'; @override - String get taskStatusInProcess => 'In process'; + String get taskStatusInProcess => 'Đang thực hiện'; @override - String get taskStatusCompleted => 'Hoàn thành'; + String get taskStatusCompleted => 'Đã hoàn thành'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Đã hủy'; @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return 'Đã hoàn thành $percentString%'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Ngày hoàn thành'; @override - String get priority => 'Priority'; + String get priority => 'Mức độ ưu tiên'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Không có mức độ ưu tiên'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Ưu tiên $priorityString · Cao'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Ưu tiên $priorityString · Trung bình'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Ưu tiên $priorityString · Thấp'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'URL công việc'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'Nhập URL tuyệt đối, bao gồm lược đồ.'; @override - String get classification => 'Classification'; + String get classification => 'Phân loại'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => 'Khi chia sẻ, hiển thị toàn bộ công việc'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => + 'Khi chia sẻ, chỉ hiển thị trạng thái bận'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'Khi chia sẻ, ẩn công việc này'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Ghim công việc'; @override String get notes => 'Ghi chú'; @@ -1058,82 +1321,86 @@ class AppLocalizationsVi extends AppLocalizations { String get addReminder => 'Thêm lời nhắc'; @override - String get reminders => 'Reminders'; + String get reminders => 'Lời nhắc'; @override - String get noReminders => 'No reminders'; + String get noReminders => 'Không có lời nhắc'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Chỉnh sửa lời nhắc'; @override - String get beforeTaskStarts => 'Before the task starts'; + String get beforeTaskStarts => 'Trước khi công việc bắt đầu'; @override - String get beforeTaskDue => 'Before the task is due'; + String get beforeTaskDue => 'Trước hạn chót của công việc'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'Sau khi công việc bắt đầu'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'Sau hạn chót của công việc'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'Theo ngày bắt đầu công việc'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'Theo ngày đến hạn công việc'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Thời gian trong ngày'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'Vào ngày và giờ cụ thể'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Số lượng'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Đơn vị'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Giây'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'Phút'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Giờ'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Ngày'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Tuần'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'Khi công việc bắt đầu'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'Khi công việc đến hạn'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'Loại lời nhắc này được giữ lại nhưng không thể chỉnh sửa thời gian.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'Giữ lời nhắc liên quan?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Ngày này có $countString lời nhắc liên quan. Giữ chúng ở ngày và giờ hiện tại?'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => 'Xóa lời nhắc'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => 'Giữ lời nhắc'; @override String get addGuest => 'Thêm khách mời'; @@ -1169,121 +1436,179 @@ class AppLocalizationsVi extends AppLocalizations { String get repeatYearly => 'Hằng năm'; @override - String get repeatEvery => 'Lặp lại mỗi'; + String get repeatEvery => 'Khoảng lặp lại'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'Lặp lại vào'; @override - String get repeatEnd => 'Kết thúc lập lại'; + String get repeatEnd => 'Kết thúc lặp lại'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'Không bao giờ'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'Vào ngày'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'Sau một số lần xuất hiện'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'Số lần lặp'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'Ngày trong tháng'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'Tháng'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'Vị trí ngày trong tuần'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'Ngày cụ thể'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'Thứ nhất'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'Thứ hai'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'Thứ ba'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'Thứ tư'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'Thứ năm'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'Áp chót'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'Cuối cùng'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'Ngày'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'Ngày trong tuần'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'Ngày cuối tuần'; + + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'Thứ Hai', + 'TU': 'Thứ Ba', + 'WE': 'Thứ Tư', + 'TH': 'Thứ Năm', + 'FR': 'Thứ Sáu', + 'SA': 'Thứ Bảy', + 'SU': 'Chủ Nhật', + 'day': 'ngày', + 'weekday': 'ngày trong tuần', + 'weekend': 'ngày cuối tuần', + 'other': '$day', + }); + return '$_temp0'; + } @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Mỗi $countString ngày'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Mỗi $countString tuần'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Mỗi $countString tháng'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Mỗi $countString năm'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return 'vào $days'; } @override String repeatOnMonthDaysSummary(String days) { - return 'on day $days'; + return 'vào ngày $days trong tháng'; } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'vào $days đầu tiên', + 'second': 'vào $days thứ hai', + 'third': 'vào $days thứ ba', + 'fourth': 'vào $days thứ tư', + 'fifth': 'vào $days thứ năm', + 'secondToLast': 'vào $days áp chót', + 'last': 'vào $days cuối cùng', + 'other': 'vào $days', + }); + return '$_temp0'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return 'vào $months'; } @override String repeatTimesSummary(int count) { - return '$count lần'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString lần', + ); + return '$_temp0'; } @override String repeatUntilSummary(String date) { - return 'cho đến $date'; + return 'đến $date'; } @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'Quy tắc lặp lại này sử dụng các tùy chọn mà trình chỉnh sửa này không thay đổi.'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'Không thể sử dụng kiểu lặp lại này với $provider.'; + } @override String get importance => 'Mức độ quan trọng'; @@ -1338,21 +1663,21 @@ class AppLocalizationsVi extends AppLocalizations { String get subtasks => 'Công việc con'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'Nhân bản công việc'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Đã nhân bản công việc.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'Không thể nhân bản công việc: $error'; } @override - String get hideSubtasks => 'Hide subtasks'; + String get hideSubtasks => 'Ẩn công việc phụ'; @override - String get hideClosedSubtasks => 'Hide closed subtasks'; + String get hideClosedSubtasks => 'Ẩn công việc phụ đã đóng'; @override String get moveToTop => 'Chuyển lên đầu'; @@ -1381,7 +1706,7 @@ class AppLocalizationsVi extends AppLocalizations { String get updated => 'Đã cập nhật'; @override - String get parent => 'Công việc cha'; + String get parent => 'Công việc chính'; @override String get position => 'Vị trí'; @@ -1408,7 +1733,11 @@ class AppLocalizationsVi extends AppLocalizations { String get sync => 'Đồng bộ'; @override - String get manualFullSync => 'Đồng bộ toàn bộ thủ công'; + String get forceFullResync => 'Buộc đồng bộ hóa lại toàn bộ'; + + @override + String get forceFullResyncDescription => + 'Tải lại toàn bộ dữ liệu từ mọi tài khoản đã kết nối. Chỉ sử dụng tùy chọn này để khắc phục sự cố đồng bộ hóa.'; @override String get runInBackgroundWhenClosed => 'Tiếp tục chạy khi đóng cửa sổ'; @@ -1453,6 +1782,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get eventReminders => 'Lời nhắc sự kiện'; + @override + String get onState => 'Bật'; + @override String get taskReminders => 'Lời nhắc công việc'; @@ -1481,6 +1813,18 @@ class AppLocalizationsVi extends AppLocalizations { @override String get notifications => 'Thông báo'; + @override + String get windowsNotificationsUnavailable => + 'Thông báo Windows không khả dụng'; + + @override + String get windowsNotificationsUnpackaged => + 'Bản chạy phát triển chưa đóng gói này không thể dùng thông báo Windows. Hãy cài đặt MSIX được ký để thử nghiệm nhằm kiểm tra lời nhắc.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax không thể khởi tạo thông báo Windows. Lời nhắc sẽ không xuất hiện cho đến khi sự cố cài đặt này được khắc phục.'; + @override String get appearance => 'Giao diện'; @@ -1490,6 +1834,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get themeSystem => 'Hệ thống'; + @override + String get settingsSystem => 'Hệ thống'; + @override String get themeLight => 'Sáng'; @@ -1567,7 +1914,11 @@ class AppLocalizationsVi extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'số_lần_thử=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'số_lần_thử=$countString'; } @override @@ -1620,10 +1971,14 @@ class AppLocalizationsVi extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Có $count công việc đến hạn hôm nay.', + other: 'Có $countString công việc đến hạn hôm nay.', one: 'Có một công việc đến hạn hôm nay.', ); return '$_temp0'; @@ -1648,7 +2003,7 @@ class AppLocalizationsVi extends AppLocalizations { String get notificationSnoozeAction => 'Báo lại sau 10 phút'; @override - String get notificationDismissAction => 'Bỏ qua'; + String get notificationDismissAction => 'Đóng'; @override String get notificationDetailsHidden => @@ -1674,7 +2029,11 @@ class AppLocalizationsVi extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'Tuần $number'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Tuần $numberString'; } @override @@ -1682,10 +2041,14 @@ class AppLocalizationsVi extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count mục', + other: '$countString mục', one: '1 mục', ); return '$_temp0'; @@ -1704,14 +2067,14 @@ class AppLocalizationsVi extends AppLocalizations { String get noLocationsFound => 'Không tìm thấy địa điểm'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'Trường này là bắt buộc.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Kết nối lịch và công việc từ một trong các nhà cung cấp sau.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Lịch Apple iCloud'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1720,198 +2083,220 @@ class AppLocalizationsVi extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Công việc Nextcloud'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'Thêm tài khoản Lịch Apple iCloud'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Thêm tài khoản Nextcloud'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Đang kết nối với Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Đang chờ ủy quyền Nextcloud…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Kết nối Lịch Apple iCloud'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Email tài khoản Apple'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'Mật khẩu dành riêng cho ứng dụng'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Tạo mật khẩu dành riêng cho ứng dụng sau khi bật xác thực hai yếu tố cho tài khoản Apple.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Đặt lại mật khẩu tài khoản Apple sẽ thu hồi các mật khẩu dành riêng cho ứng dụng.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Kết nối Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Máy chủ Nextcloud hoặc địa chỉ CalDAV'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Nhập URL máy chủ Nextcloud hoặc dán địa chỉ CalDAV chính được sao chép từ Nextcloud.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax sẽ mở trình duyệt. Phê duyệt quyền truy cập ở đó rồi quay lại BusyMax.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Kết nối'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Hủy kết nối'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'Tài khoản đã bị xóa cục bộ nhưng không thể thu hồi mật khẩu ứng dụng Nextcloud.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'Dữ liệu lịch và công việc được lưu trong bộ nhớ đệm cục bộ để sử dụng ngoại tuyến.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Kết nối lại tài khoản này để tiếp tục đồng bộ.'; @override String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + 'Tài khoản này tạm thời không khả dụng.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'Quyền máy chủ đã thay đổi. Các chỉnh sửa đang chờ bị tạm dừng.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Máy chủ hoặc hồ sơ nhà cung cấp này không được hỗ trợ.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Lịch và danh sách công việc'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'Sự kiện lịch'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Công việc'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Chỉ đọc'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'Đang chờ cục bộ'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Bị chặn do xung đột'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'Bị chặn cho đến khi kết nối lại'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'Thao tác không thành công'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Giữ phiên bản máy chủ'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'Xem lại và áp dụng lại thay đổi cục bộ'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Nhân bản thành mục mới'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'Trạng thái kết nối'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Đã kết nối'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'Đang kết nối…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Đã đăng xuất'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Lần đồng bộ thành công gần nhất: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Chưa đồng bộ'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'Làm mới lịch và danh sách công việc'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Máy chủ: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Lịch sự kiện'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Danh sách công việc'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'Sự kiện và công việc'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Có thể ghi'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Đã chia sẻ'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Đồng bộ lần cuối: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Sự cố đồng bộ: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Xung đột đồng bộ'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Máy chủ đã thay đổi: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Chỉnh sửa cục bộ: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'Không thể giải quyết xung đột.'; + + @override + String get recurringEventScope => 'Phạm vi sự kiện lặp lại'; + + @override + String get entireSeries => 'Toàn bộ chuỗi'; + + @override + String get singleOccurrence => 'Sự kiện này'; @override - String get recurringEventScope => 'Recurring event scope'; + String get thisAndFollowingEvents => 'Sự kiện này và các sự kiện tiếp theo'; @override - String get entireSeries => 'Entire series'; + String get thisAndFutureUnavailable => 'Nhà cung cấp này không hỗ trợ.'; @override - String get singleOccurrence => 'This occurrence'; + String get thisAndFutureMoveUnavailable => + 'Không thể di chuyển an toàn sự kiện này và các sự kiện tiếp theo. Hãy chọn sự kiện này hoặc toàn bộ chuỗi.'; @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get entireSeriesMoveUnavailable => + 'Quy tắc lặp lại không có sẵn trên thiết bị. Hãy chỉ di chuyển sự kiện này.'; + + @override + String get copyEventAndDeleteOriginal => 'Sao chép sự kiện và xóa bản gốc?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax không thể di chuyển trực tiếp sự kiện này từ $source sang $destination. Ứng dụng sẽ tạo bản sao trước và chỉ xóa bản gốc sau khi sao chép thành công. ID sự kiện sẽ thay đổi; trạng thái phản hồi của người tham dự có thể bị đặt lại và lời mời hoặc thông báo hủy có thể được gửi; liên kết cuộc họp, tệp đính kèm, lời nhắc, trường riêng của nhà cung cấp và ngoại lệ lặp lại có thể không được chuyển sang.'; + } + + @override + String get copyAndDelete => 'Sao chép và xóa'; @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Chọn áp dụng thay đổi này cho toàn bộ chuỗi, chỉ sự kiện này hay sự kiện này và các sự kiện tiếp theo.'; @override String get taskDueBeforeStart => @@ -1926,6 +2311,198 @@ class AppLocalizationsVi extends AppLocalizations { return 'Xóa “$title”?'; } + @override + String get setCustomCalendarName => 'Đặt tên tùy chỉnh'; + + @override + String get setAction => 'Đặt'; + + @override + String get removeFromMyCalendars => 'Xóa khỏi lịch của tôi'; + + @override + String get removeAction => 'Xóa'; + + @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.'; + } + + @override + String get calendarCannotRemove => + 'Không thể xóa hoặc gỡ lịch này khỏi tài khoản.'; + + @override + String get 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.'; + + @override + String get calendarSubscriptions => 'Gói đăng ký lịch'; + + @override + String get calendarSubscriptionsDescription => + 'Thêm lịch chỉ đọc được làm mới từ URL WebCal bảo mật.'; + + @override + String get addCalendarSubscription => 'Thêm gói đăng ký lịch'; + + @override + String get subscriptionName => 'Tên cục bộ'; + + @override + String get subscriptionUrl => 'URL gói đăng ký'; + + @override + String get subscriptionUrlHelp => + 'Nhập URL HTTPS hoặc webcal. BusyMax giữ URL đầy đủ trong bộ nhớ an toàn.'; + + @override + String get subscriptionUrlInvalid => + 'Nhập URL HTTPS hoặc webcal hợp lệ không có thông tin người dùng hoặc phân đoạn.'; + + @override + String get subscriptionColor => 'Màu cục bộ'; + + @override + String get subscriptionColorHelp => 'Sử dụng màu sáu chữ số như #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Nhập màu thập lục phân sáu chữ số.'; + + @override + String get subscriptionRefreshMode => 'Tần suất làm mới'; + + @override + String get subscriptionAutomatic => 'Tự động'; + + @override + String get subscriptionHourly => 'Hàng giờ'; + + @override + String get subscriptionSixHours => 'Mỗi sáu giờ'; + + @override + String get subscriptionDaily => 'Hàng ngày'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Nguồn: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Nhập URL hợp lệ để xem trước nguồn an toàn.'; + + @override + String get subscriptionReadOnly => 'Gói đăng ký chỉ đọc'; + + @override + String get subscriptionNeverRefreshed => 'Chưa làm mới'; + + @override + String subscriptionLastRefresh(String time) { + return 'Lần làm mới thành công gần nhất: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Lần làm mới tiếp theo: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Đã cập nhật'; + + @override + String subscriptionStatusIssue(String code) { + return 'Sự cố làm mới: $code'; + } + + @override + String get refreshNow => 'Làm mới ngay'; + + @override + String get unsubscribe => 'Hủy đăng ký'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Hủy đăng ký “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'Thao tác này xóa gói đăng ký cục bộ và các sự kiện đã lưu trong bộ nhớ đệm. Lịch đã xuất bản không thay đổi.'; + + @override + String get addSubscriptionAction => 'Thêm gói đăng ký'; + + @override + String subscriptionOperationFailed(String error) { + return 'Gói đăng ký lịch không thành công: $error'; + } + + @override + String get subscriptions => 'Gói đăng ký'; + + @override + String get calendarImport => 'Nhập lịch'; + + @override + String get calendarImportDescription => + 'Chọn tệp, xem lại các sự kiện rồi chọn lịch có thể ghi để nhận chúng.'; + + @override + String get importIcsFile => 'Nhập tệp .ics'; + + @override + String get importIcsPreview => 'Nhập sự kiện lịch'; + + @override + String importEventsFound(int count) { + return 'Bộ sự kiện có thể nhập: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Sự kiện không hợp lệ: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Cố ý bỏ qua: $fields'; + } + + @override + String get noWritableCalendars => 'Không có lịch đích có thể ghi.'; + + @override + String get importDestinationCalendar => 'Lịch đích'; + + @override + String get importIcsConfirm => 'Nhập sự kiện'; + + @override + String get importIcsComplete => 'Đã nhập xong'; + + @override + String importQueued(int count) { + return 'Đã nhập hoặc xếp hàng: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Đã bỏ qua bản sao: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Bộ lặp lại không được hỗ trợ: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Không thể nhập tệp lịch: $error'; + } + @override String get networkOffline => 'Ngoại tuyến'; @@ -1936,4 +2513,139 @@ class AppLocalizationsVi extends AppLocalizations { @override String get networkOfflineTryAgain => 'Bạn đang ngoại tuyến. Hãy kết nối Internet rồi thử lại.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'vào các ngày $days trong tháng'; + } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(String day) { + return '$day'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first và $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first và $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first và $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first và $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency vào ngày $day $month'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency vào các ngày $days của $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency vào ngày $day của $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency vào các ngày $days của $months'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'đầu tiên', + 'second': 'thứ hai', + 'third': 'thứ ba', + 'fourth': 'thứ tư', + 'fifth': 'thứ năm', + 'secondToLast': 'áp chót', + 'last': 'cuối cùng', + 'other': '', + }); + return '$frequency vào $days $_temp0 của $month'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'đầu tiên', + 'second': 'thứ hai', + 'third': 'thứ ba', + 'fourth': 'thứ tư', + 'fifth': 'thứ năm', + 'secondToLast': 'áp chót', + 'last': 'cuối cùng', + 'other': '', + }); + return '$frequency vào $days $_temp0 của $months'; + } } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 26da6b92..d7072bc7 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -12,12 +12,107 @@ import 'app_localizations.dart'; class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); + @override + String get windowsSupport => '支持'; + + @override + String get windowsThirdPartyLicenses => '第三方许可证'; + + @override + String get windowsSearch => '搜索'; + + @override + String get windowsStartupDisabledByUser => '用户已在 Windows 设置中禁用。'; + + @override + String get windowsStartupDisabledByPolicy => '已由 Windows 策略禁用。'; + + @override + String get windowsStartupUnavailable => '从 MSIX 包安装 BusyMax 后可用。'; + + @override + String get windowsReminderExitNotice => + '完全退出 BusyMax 后,提醒将停止。请让应用在后台运行以接收提醒。'; + + @override + String get windowsProductVersionLabel => '产品版本'; + + @override + String get windowsPackageVersionLabel => 'Windows 包版本'; + + @override + String get windowsUnpackaged => '未打包'; + + @override + String get windowsAgendaLoadMore => '載入更多議程項目'; + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '星期一', + 'TU': '星期二', + 'WE': '星期三', + 'TH': '星期四', + 'FR': '星期五', + 'SA': '星期六', + 'SU': '星期日', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return '$first、$second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency$month的$firstDay和$secondDay'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency$firstMonth和$secondMonth的$day'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency$firstMonth和$secondMonth的$firstDay和$secondDay'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency$firstMonth和$secondMonth的$days'; + } + @override String get appTitle => 'BusyMax'; @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + '连接 Google、Microsoft、Apple iCloud Calendar 或 Nextcloud 账户。'; @override String get googlePermissionsConsentNotice => '在 Google 权限页面上,同时选择日历和任务权限。'; @@ -36,11 +131,11 @@ class AppLocalizationsZh extends AppLocalizations { String get onboardingSetupTitle => '设置 BusyMax'; @override - String get onboardingAccountsStepTitle => '连接帐户'; + String get onboardingAccountsStepTitle => '连接账户'; @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + '添加所有要使用的账户。BusyMax 会同步每个账户中受支持的日历、日程、任务列表和任务。'; @override String get onboardingPreferencesStepTitle => '选择系统设置'; @@ -116,6 +211,42 @@ class AppLocalizationsZh extends AppLocalizations { @override String get calendars => '日历'; + @override + String get newCalendar => '新建日历'; + + @override + String get calendarColor => '日历颜色'; + + @override + String calendarColorOption(int number) { + return '颜色 $number'; + } + + @override + String get calendarManagementUnsupported => '此提供商不支持 BusyMax 中的日历管理。'; + + @override + String get primaryCalendarCannotDelete => '无法删除主日历。'; + + @override + String calendarCreateFailed(String error) { + return '无法创建日历:$error'; + } + + @override + String get calendarCreatedRefreshPending => + '日历已创建,但 BusyMax 无法刷新账户。它将在下次同步后显示。'; + + @override + String calendarUpdateFailed(String error) { + return '无法更新日历:$error'; + } + + @override + String calendarDeleteFailed(String error) { + return '无法删除日历:$error'; + } + @override String get newEvent => '新建日程'; @@ -139,7 +270,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String moreItems(int count) { - return '还有 $count 项'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '还有 $countString 项'; } @override @@ -158,7 +293,7 @@ class AppLocalizationsZh extends AppLocalizations { String get scheduleNoSourcesDescription => '请在设置中选择要显示的内容,然后刷新。'; @override - String get scheduleSignInRequired => '连接帐户'; + String get scheduleSignInRequired => '连接账户'; @override String get scheduleSignInDescription => '登录以同步日历和任务。'; @@ -175,6 +310,101 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trayOpenBusyMax => '打开 BusyMax'; + @override + String get trayShowBusyMax => '显示 BusyMax'; + + @override + String get trayNewEvent => '新日程…'; + + @override + String get trayNewTask => '新任务…'; + + @override + String get trayToday => '今天'; + + @override + String get trayAllDay => '全天'; + + @override + String get trayNow => '现在'; + + @override + String get trayCalendarEvent => '日历日程'; + + @override + String get trayUntitledEvent => '无标题日程'; + + @override + String get trayNothingElseToday => '今天没有其他内容'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '今天有 $count 个任务到期', + one: '今天有 1 个任务到期', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => '打开今天的日程'; + + @override + String get traySyncNow => '立即同步'; + + @override + String get traySyncing => '正在同步…'; + + @override + String get trayNotConnected => '未连接'; + + @override + String get trayNotYetSynced => '尚未同步'; + + @override + String get trayLastSyncedJustNow => '刚刚同步'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 分钟前同步', + one: '1 分钟前同步', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 小时前同步', + one: '1 小时前同步', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 天前同步', + one: '1 天前同步', + ); + return '$_temp0'; + } + + @override + String get traySettings => '设置'; + + @override + String get trayQuitBusyMax => '退出 BusyMax'; + @override String get agendaLoadMoreOverdue => '加载更多逾期任务'; @@ -255,130 +485,123 @@ class AppLocalizationsZh extends AppLocalizations { String get guests => '参与者'; @override - String get noGuests => '没有参与者'; + String get noGuests => '无参与者'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => '必需'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => '可选'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => '会议'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => '添加 Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => '添加 Microsoft Teams 会议'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => '已添加在线会议'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => '请求回复'; @override - String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + String get requestResponsesDescription => '要求参与者回复邀请。'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => '隐藏参与者列表'; @override - String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + String get hideGuestListDescription => '参与者无法查看其他受邀者。'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => '允许提出新时间'; @override - String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + String get allowNewTimeProposalsDescription => '参与者可以建议其他会议时间。'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => '通知参与者?'; @override - String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + String get notifyGuestsSaveMessage => '此会议有参与者。保存时发送邀请或日程更新吗?'; @override - String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + String get notifyGuestsDeleteMessage => '此会议有参与者。删除时发送取消通知吗?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => '发送更新'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => '发送取消通知'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => '不发送'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => '保存会议?'; @override - String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + String get microsoftNotifyGuestsSaveMessage => 'Microsoft 将向参与者发送邀请或日程更新。'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => '删除会议?'; @override - String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + String get microsoftNotifyGuestsDeleteMessage => 'Microsoft 将向参与者发送取消通知。'; @override - String get organizer => 'Organizer'; + String get organizer => '组织者'; @override - String get yourResponse => 'Your response'; + String get yourResponse => '你的回复'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => '参与者回复'; @override - String get respond => 'Respond'; + String get respond => '回复'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => '接受'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => '暂定'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => '拒绝'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => '加入会议'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => '已接受'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => '暂定'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => '已拒绝'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => '等待回复'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => '未回复'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => '组织者'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return '无法发送回复:$error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => '无法打开会议链接。'; @override String get description => '说明'; @@ -424,10 +647,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes 分钟前', + other: '$minutesString 分钟前', one: '1 分钟前', ); return '$_temp0'; @@ -438,10 +665,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours 小时前', + other: '$hoursString 小时前', one: '1 小时前', ); return '$_temp0'; @@ -449,10 +680,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days 天前', + other: '$daysString 天前', one: '1 天前', ); return '$_temp0'; @@ -633,7 +869,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - '仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、帐户数据、文件名或其他诊断信息。'; + '仅添加您的操作系统名称和版本以及应用区域设置。不包含日志、账户数据、文件名或其他诊断信息。'; @override String get feedbackCategoryRequired => '请选择类别。'; @@ -677,19 +913,19 @@ class AppLocalizationsZh extends AppLocalizations { String get hideSidebar => '隐藏侧边栏面板'; @override - String get accounts => '帐户'; + String get accounts => '账户'; @override - String get currentAccount => '当前帐户'; + String get currentAccount => '当前账户'; @override - String get switchAccount => '切换帐户'; + String get switchAccount => '切换账户'; @override - String get addGoogleAccount => '添加 Google 帐户'; + String get addGoogleAccount => '添加 Google 账户'; @override - String get addMicrosoftAccount => '添加 Microsoft 帐户'; + String get addMicrosoftAccount => '添加 Microsoft 账户'; @override String get googleProvider => 'Google'; @@ -701,13 +937,13 @@ class AppLocalizationsZh extends AppLocalizations { String get signedInAccount => '已登录'; @override - String get removeAccount => '移除帐户…'; + String get removeAccount => '移除账户…'; @override - String get removingAccount => '正在移除帐户…'; + String get removingAccount => '正在移除账户…'; @override - String get removeAccountDescription => '停止同步并从此设备移除此帐户的数据。'; + String get removeAccountDescription => '停止同步并从此设备移除此账户的数据。'; @override String removeAccountTitle(String account) { @@ -716,40 +952,40 @@ class AppLocalizationsZh extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + '这会从此设备删除缓存的任务、日历、日程、提醒和待处理的离线更改。未同步的更改将会丢失。提供商中的日历、日程、任务列表和任务副本不会被删除。'; @override - String get revokeGoogleAccess => '同时撤销 BusyMax 对此 Google 帐户的访问权限'; + String get revokeGoogleAccess => '同时撤销 BusyMax 对此 Google 账户的访问权限'; @override String get revokeGoogleAccessDescription => '重新连接之前,您需要再次授予访问权限。'; @override - String get removeAccountAction => '移除帐户'; + String get removeAccountAction => '移除账户'; @override - String get removeAccountFailed => '无法完成帐户移除。请重试。'; + String get removeAccountFailed => '无法完成账户移除。请重试。'; @override String get accountRemovedGoogleRevokeFailed => - '该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。'; + '该账户已从此设备移除,但无法撤销 BusyMax 对您的 Google 账户的访问权限。您可以在 Google 账户中手动撤销该权限。'; @override - String get newTaskList => '新建任务列表'; + String get newTaskList => '新任务列表'; @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return '无法创建任务列表:$error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return '无法重命名任务列表:$error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return '无法删除任务列表:$error'; } @override @@ -777,12 +1013,10 @@ class AppLocalizationsZh extends AppLocalizations { String get unshare => '取消共享'; @override - String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + String get readOnlyTaskListCannotRename => '此任务列表为只读,无法重命名。'; @override - String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + String get taskListCannotDelete => '使用当前权限无法删除此任务列表。'; @override String get builtInMicrosoftList => '内置'; @@ -793,17 +1027,17 @@ class AppLocalizationsZh extends AppLocalizations { @override String deleteListConfirmation(String title) { - return '从 Google Tasks 中删除“$title”?'; + return '要从 Google Tasks 中删除“$title”吗?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return '要删除“$title”及其所有任务吗?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return '要取消此账户对“$title”的共享吗?'; } @override @@ -831,7 +1065,7 @@ class AppLocalizationsZh extends AppLocalizations { String get listRefreshed => '列表已刷新。'; @override - String get allTasksRefreshed => '所有帐户均已刷新。'; + String get allTasksRefreshed => '所有账户均已刷新。'; @override String exportedFile(String path) { @@ -861,7 +1095,7 @@ class AppLocalizationsZh extends AppLocalizations { String get noTasksYet => '还没有任务'; @override - String get noTasksYetMessage => '创建任务或刷新帐户以开始使用。'; + String get noTasksYetMessage => '创建任务或刷新账户以开始使用。'; @override String get noTasksInList => '此列表中没有任务。'; @@ -928,72 +1162,88 @@ class AppLocalizationsZh extends AppLocalizations { String get doneStatus => '已完成'; @override - String get taskStatus => 'Status'; + String get taskStatus => '状态'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => '无状态'; @override String get taskStatusNeedsAction => '需要操作'; @override - String get taskStatusInProcess => '处理中'; + String get taskStatusInProcess => '进行中'; @override - String get taskStatusCompleted => '完成'; + String get taskStatusCompleted => '已完成'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => '已取消'; @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '已完成 $percentString%'; } @override - String get completionDate => 'Completion date'; + String get completionDate => '完成日期'; @override String get priority => '优先级'; @override - String get priorityNone => 'No priority'; + String get priorityNone => '无优先级'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '优先级 $priorityString · 高'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '优先级 $priorityString · 中'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '优先级 $priorityString · 低'; } @override - String get taskUrl => 'URL'; + String get taskUrl => '任务 URL'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => '请输入包含方案的绝对 URL。'; @override - String get classification => 'Classification'; + String get classification => '分类'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => '共享时显示完整任务'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => '共享时仅显示忙碌状态'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => '共享时隐藏此任务'; @override - String get pinTask => 'Pin task'; + String get pinTask => '固定任务'; @override String get notes => '备注'; @@ -1032,75 +1282,78 @@ class AppLocalizationsZh extends AppLocalizations { String get addReminder => '添加提醒'; @override - String get reminders => 'Reminders'; + String get reminders => '提醒'; @override String get noReminders => '无提醒'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => '编辑提醒'; @override String get beforeTaskStarts => '任务开始前'; @override - String get beforeTaskDue => '任务截止前'; + String get beforeTaskDue => '任务到期前'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => '任务开始后'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => '任务到期后'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => '相对于任务开始日期'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => '相对于任务到期日期'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => '时间'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => '在指定日期和时间'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => '数量'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => '单位'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => '秒'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => '分钟'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => '小时'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => '天'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => '周'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => '任务开始时'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => '任务到期时'; @override - String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + String get unsupportedReminder => '此提醒类型会保留,但无法编辑其时间。'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => '保留相关提醒?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '此日期有 $countString 个相关提醒。要保留它们当前的日期和时间吗?'; } @override @@ -1143,86 +1396,120 @@ class AppLocalizationsZh extends AppLocalizations { String get repeatYearly => '每年'; @override - String get repeatEvery => '重复每'; + String get repeatEvery => '重复间隔'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => '重复日期'; @override String get repeatEnd => '结束重复'; @override - String get repeatNever => 'Never'; + String get repeatNever => '从不'; + + @override + String get repeatUntil => '指定日期'; @override - String get repeatUntil => 'On date'; + String get repeatAfter => '指定次数后'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatCount => '重复次数'; @override - String get repeatCount => 'Occurrences'; + String get repeatDayOfMonth => '每月日期'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatMonths => '月份'; @override - String get repeatMonths => 'Months'; + String get repeatOrdinal => '星期位置'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatSpecificDays => '特定日期'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatFirst => '第一'; @override - String get repeatFirst => 'First'; + String get repeatSecond => '第二'; @override - String get repeatSecond => 'Second'; + String get repeatThird => '第三'; @override - String get repeatThird => 'Third'; + String get repeatFourth => '第四'; @override - String get repeatFourth => 'Fourth'; + String get repeatFifth => '第五'; @override - String get repeatFifth => 'Fifth'; + String get repeatSecondToLast => '倒数第二'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatLast => '最后'; @override - String get repeatLast => 'Last'; + String get repeatAnyDay => '日期'; @override - String get repeatAnyDay => 'Day'; + String get repeatWeekday => '工作日'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekendDay => '周末'; @override - String get repeatWeekendDay => 'Weekend day'; + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '星期一', + 'TU': '星期二', + 'WE': '星期三', + 'TH': '星期四', + 'FR': '星期五', + 'SA': '星期六', + 'SU': '星期日', + 'day': '日期', + 'weekday': '工作日', + 'weekend': '周末', + 'other': '$day', + }); + return '$_temp0'; + } @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 天'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 周'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 个月'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 年'; } @override @@ -1232,12 +1519,22 @@ class AppLocalizationsZh extends AppLocalizations { @override String repeatOnMonthDaysSummary(String days) { - return '在第 $days 天'; + return '$days'; } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一个$days', + 'second': '第二个$days', + 'third': '第三个$days', + 'fourth': '第四个$days', + 'fifth': '第五个$days', + 'secondToLast': '倒数第二个$days', + 'last': '最后一个$days', + 'other': '$days', + }); + return '$_temp0'; } @override @@ -1247,17 +1544,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count次'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '重复 $countString 次', + ); + return '$_temp0'; } @override String repeatUntilSummary(String date) { - return '至 $date'; + return '直到 $date'; } @override - String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + String get unsupportedRecurrencePreserved => '此重复规则使用了编辑器不会更改的选项。'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return '此重复规则不能用于 $provider。'; + } @override String get importance => '重要性'; @@ -1302,7 +1612,7 @@ class AppLocalizationsZh extends AppLocalizations { String get list => '列表'; @override - String get microsoftMoveUnsupported => '此版本不支持在 Microsoft To Do 帐户的列表之间移动任务。'; + String get microsoftMoveUnsupported => '此版本不支持在 Microsoft To Do 账户的列表之间移动任务。'; @override String get createSubtask => '创建子任务'; @@ -1314,18 +1624,18 @@ class AppLocalizationsZh extends AppLocalizations { String get duplicateTask => '复制任务'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => '任务已复制。'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return '无法复制任务:$error'; } @override String get hideSubtasks => '隐藏子任务'; @override - String get hideClosedSubtasks => '隐藏关闭的子任务'; + String get hideClosedSubtasks => '隐藏已关闭的子任务'; @override String get moveToTop => '移到顶部'; @@ -1375,13 +1685,17 @@ class AppLocalizationsZh extends AppLocalizations { String get synced => '已同步'; @override - String get account => '帐户'; + String get account => '账户'; @override String get sync => '同步'; @override - String get manualFullSync => '手动完整同步'; + String get forceFullResync => '强制完全重新同步'; + + @override + String get forceFullResyncDescription => + '从每个已连接的账号中重新完整加载所有数据。请仅在排查同步问题时使用此功能。'; @override String get runInBackgroundWhenClosed => '窗口关闭后继续在后台运行'; @@ -1424,6 +1738,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get eventReminders => '日程提醒'; + @override + String get onState => '开启'; + @override String get taskReminders => '任务提醒'; @@ -1451,6 +1768,17 @@ class AppLocalizationsZh extends AppLocalizations { @override String get notifications => '通知'; + @override + String get windowsNotificationsUnavailable => 'Windows 通知不可用'; + + @override + String get windowsNotificationsUnpackaged => + '此未打包的开发版本无法使用 Windows 通知。请安装测试签名的 MSIX 以测试提醒。'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax 无法初始化 Windows 通知。在解决此安装问题之前,提醒不会显示。'; + @override String get appearance => '外观'; @@ -1460,6 +1788,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get themeSystem => '系统'; + @override + String get settingsSystem => '系统'; + @override String get themeLight => '浅色'; @@ -1534,7 +1865,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String pendingOpAttempts(int count) { - return '尝试次数=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '尝试次数=$countString'; } @override @@ -1586,10 +1921,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '今天有 $count 项任务到期。', + other: '今天有 $countString 项任务到期。', one: '今天有 1 项任务到期。', ); return '$_temp0'; @@ -1614,7 +1953,7 @@ class AppLocalizationsZh extends AppLocalizations { String get notificationSnoozeAction => '10 分钟后提醒'; @override - String get notificationDismissAction => '忽略'; + String get notificationDismissAction => '关闭'; @override String get notificationDetailsHidden => '根据隐私设置,详细信息已隐藏。'; @@ -1639,7 +1978,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String weekNumberTooltip(int number) { - return '第 $number 周'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return '第 $numberString 周'; } @override @@ -1647,10 +1990,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count 项', + other: '$countString 项', one: '1 项', ); return '$_temp0'; @@ -1669,14 +2016,13 @@ class AppLocalizationsZh extends AppLocalizations { String get noLocationsFound => '未找到地点'; @override - String get requiredField => 'This field is required.'; + String get requiredField => '此字段为必填项。'; @override - String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + String get providerConnectionDescription => '连接以下任一提供商中的日历和任务。'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Apple iCloud 日历'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1685,2121 +2031,3230 @@ class AppLocalizationsZh extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Nextcloud 任务'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => '添加 Apple iCloud 日历账户'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => '添加 Nextcloud 账户'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => '正在连接 Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => '正在等待 Nextcloud 授权…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => '连接 Apple iCloud 日历'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Apple 账户电子邮件'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'App 专用密码'; @override - String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + String get appleAppSpecificPasswordHelp => '为 Apple 账户启用双重认证后,创建 App 专用密码。'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + '重置 Apple 账户密码会撤销 App 专用密码。'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => '连接 Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Nextcloud 服务器或 CalDAV 地址'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + '输入 Nextcloud 服务器 URL,或粘贴从 Nextcloud 复制的主要 CalDAV 地址。'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax 将打开浏览器。请在那里批准访问,然后返回 BusyMax。'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => '连接'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => '取消连接'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + '账户已在本地移除,但无法撤销 Nextcloud App 密码。'; @override - String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + String get davCachedOfflineNotice => '日历和任务数据会缓存在本地,以供离线使用。'; @override - String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + String get davReauthenticationRequired => '重新连接此账户以恢复同步。'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get davTemporarilyUnavailable => '此账户暂时不可用。'; @override - String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + String get davPermissionChanged => '服务器权限已更改。待处理的编辑已暂停。'; @override - String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + String get davUnsupportedServer => '不支持此服务器或提供商配置。'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => '日历和任务列表'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => '日历日程'; @override - String get taskContent => 'Tasks'; + String get taskContent => '任务'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => '只读'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => '本地待处理'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => '因冲突而阻止'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => '重新连接前阻止'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => '操作失败'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => '保留服务器版本'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => '查看并重新应用本地更改'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => '复制为新项目'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => '连接状态'; @override - String get davConnected => 'Connected'; + String get davConnected => '已连接'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => '正在连接…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => '已退出登录'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return '上次成功同步:$time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => '尚未同步'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => '刷新日历和任务列表'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return '服务器:$host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => '日程日历'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => '任务列表'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => '日程和任务'; @override - String get writableCollection => 'Writable'; + String get writableCollection => '可写'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => '已共享'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return '上次同步:$time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return '同步问题:$code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => '同步冲突'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return '服务器更改:$time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return '本地编辑:$summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => '无法解决冲突。'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => '重复日程范围'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => '整个系列'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => '此事件'; @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get thisAndFollowingEvents => '此事件及后续事件'; @override - String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + String get thisAndFutureUnavailable => '此提供商不支持。'; @override - String get taskDueBeforeStart => '截止时间不能早于开始时间。'; + String get thisAndFutureMoveUnavailable => '无法安全移动此日程及后续日程。请选择此日程或整个系列。'; @override - String get taskStartDueTimeModeMismatch => '请同时设置开始和截止时间,或将任务设为全天。'; + String get entireSeriesMoveUnavailable => '本地没有可用的重复规则。请仅移动此日程。'; @override - String deleteCalendarConfirmation(String title) { - return '删除“$title”?'; - } + String get copyEventAndDeleteOriginal => '复制日程并删除原日程?'; @override - String get networkOffline => '离线'; + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax 无法将此日程直接从 $source 移动到 $destination。应用会先创建副本,并且仅在复制成功后删除原日程。日程 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。'; + } @override - String get networkOfflineDescription => '恢复连接后将同步更改。'; + String get copyAndDelete => '复制并删除'; @override - String get networkOfflineTryAgain => '您当前处于离线状态。请连接互联网后重试。'; -} + String get chooseRecurringEventScope => '选择此更改适用于整个系列、仅此日程,还是此日程及后续日程。'; -/// The translations for Chinese, using the Han script (`zh_Hans`). -class AppLocalizationsZhHans extends AppLocalizationsZh { - AppLocalizationsZhHans() : super('zh_Hans'); + @override + String get taskDueBeforeStart => '截止时间不能早于开始时间。'; @override - String get appTitle => 'BusyMax'; + String get taskStartDueTimeModeMismatch => '请同时设置开始和截止时间,或将任务设为全天。'; @override - String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + String deleteCalendarConfirmation(String title) { + return '删除“$title”?'; + } @override - String get googlePermissionsConsentNotice => '在 Google 权限页面上,同时选择日历和任务权限。'; + String get setCustomCalendarName => '设置自定义名称'; @override - String get googlePermissionsRequiredRetry => - '必须授予 Google 日历和 Google Tasks 权限。请重试并选中两个复选框。'; + String get setAction => '设置'; @override - String get finishSetup => '完成设置'; + String get removeFromMyCalendars => '从“我的日历”中移除'; @override - String get continueSetup => '继续'; + String get removeAction => '移除'; @override - String get onboardingSetupTitle => '设置 BusyMax'; + String removeCalendarConfirmation(String title) { + return '要从您的 Google 日历列表中移除“$title”吗?共享日历及其日程不会被删除。'; + } @override - String get onboardingAccountsStepTitle => '连接帐户'; + String get calendarCannotRemove => '无法从此账户删除或移除此日历。'; @override - String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + String get calendarPendingChangesPreventRemoval => + '请等待此日历的待处理更改完成同步,然后再删除或移除它。'; @override - String get onboardingPreferencesStepTitle => '选择系统设置'; + String get calendarSubscriptions => '日历订阅'; @override - String get onboardingPreferencesStepDescription => - '打开日程前,请设置桌面行为、提醒、通知详细程度和外观。'; + String get calendarSubscriptionsDescription => '添加从安全 WebCal URL 刷新的只读日历。'; @override - String get signInWithGoogle => '使用 Google 登录'; + String get addCalendarSubscription => '添加日历订阅'; @override - String get signInWithMicrosoft => '使用 Microsoft 登录'; + String get subscriptionName => '本地名称'; @override - String get googleTasksProvider => 'Google Tasks'; + String get subscriptionUrl => '订阅 URL'; @override - String get microsoftTodoProvider => 'Microsoft To Do'; + String get subscriptionUrlHelp => + '输入 HTTPS 或 webcal URL。BusyMax 会将完整 URL 保存在安全存储中。'; @override - String get providerNotConfigured => '尚未配置此服务。'; + String get subscriptionUrlInvalid => '请输入不含用户信息或片段的有效 HTTPS 或 webcal URL。'; @override - String get waitingForGoogleSignIn => '正在等待 Google 登录...'; + String get subscriptionColor => '本地颜色'; @override - String get waitingForMicrosoftSignIn => '正在等待 Microsoft 登录...'; + String get subscriptionColorHelp => '使用六位颜色,例如 #3584E4。'; @override - String get microsoftSignInNotConfigured => - '尚未配置 Microsoft 登录。请设置 MICROSOFT_OAUTH_CLIENT_ID。'; + String get subscriptionColorInvalid => '请输入六位十六进制颜色。'; @override - String get cancel => '取消'; + String get subscriptionRefreshMode => '刷新频率'; @override - String get close => '关闭'; + String get subscriptionAutomatic => '自动'; @override - String get exit => '退出'; + String get subscriptionHourly => '每小时'; @override - String get options => '选项'; + String get subscriptionSixHours => '每六小时'; @override - String get hide => '隐藏'; + String get subscriptionDaily => '每天'; @override - String get show => '显示'; + String subscriptionSafeOrigin(String origin) { + return '来源:$origin'; + } @override - String get export => '导出'; + String get subscriptionSafeOriginUnavailable => '请输入有效 URL 以预览其安全来源。'; @override - String get save => '保存'; + String get subscriptionReadOnly => '只读订阅'; @override - String get settings => '设置'; + String get subscriptionNeverRefreshed => '尚未刷新'; @override - String get all => '全部'; + String subscriptionLastRefresh(String time) { + return '上次成功刷新:$time'; + } @override - String get calendarEvents => '日程'; + String subscriptionNextRefresh(String time) { + return '下次刷新:$time'; + } @override - String get calendarTasks => '任务'; + String get subscriptionStatusHealthy => '已是最新'; @override - String get calendar => '日历'; + String subscriptionStatusIssue(String code) { + return '刷新问题:$code'; + } @override - String get calendars => '日历'; + String get refreshNow => '立即刷新'; @override - String get newEvent => '新建日程'; + String get unsubscribe => '取消订阅'; @override - String get refreshCalendar => '刷新日历'; + String unsubscribeCalendarTitle(String name) { + return '要取消订阅“$name”吗?'; + } @override - String get openInProvider => '在服务中打开'; + String get unsubscribeCalendarConfirmation => '这会移除本地订阅及其缓存的日程。已发布的日历不会更改。'; @override - String get hideFromSchedule => '从日程中隐藏'; + String get addSubscriptionAction => '添加订阅'; @override - String get showInSchedule => '在日程中显示'; + String subscriptionOperationFailed(String error) { + return '日历订阅失败:$error'; + } @override - String get noCalendarsSynced => '尚未同步任何日历。'; + String get subscriptions => '订阅'; @override - String get allDay => '全天'; + String get calendarImport => '日历导入'; @override - String moreItems(int count) { - return '还有 $count 项'; - } + String get calendarImportDescription => '选择文件,查看其中的日程,然后选择接收这些日程的可写日历。'; @override - String get noEventsOrTasks => '没有日程或任务'; + String get importIcsFile => '导入 .ics 文件'; @override - String get scheduleLoading => '正在加载日程...'; + String get importIcsPreview => '导入日历日程'; @override - String get scheduleUnavailable => '日程不可用'; + String importEventsFound(int count) { + return '可导入日程集:$count'; + } @override - String get scheduleNoSources => '没有可见的日历或任务列表'; + String importInvalidEvents(int count) { + return '无效日程:$count'; + } @override - String get scheduleNoSourcesDescription => '请在设置中选择要显示的内容,然后刷新。'; + String importFieldsOmitted(String fields) { + return '有意省略:$fields'; + } @override - String get scheduleSignInRequired => '连接帐户'; + String get noWritableCalendars => '没有可用的可写目标日历。'; @override - String get scheduleSignInDescription => '登录以同步日历和任务。'; + String get importDestinationCalendar => '目标日历'; @override - String get scheduleNoSearchResults => '没有匹配的日程或任务'; + String get importIcsConfirm => '导入日程'; @override - String get scheduleNoSearchResultsDescription => '请尝试其他搜索内容或清除当前筛选条件。'; + String get importIcsComplete => '导入完成'; @override - String get refresh => '刷新'; + String importQueued(int count) { + return '已导入或已排队:$count'; + } @override - String get trayOpenBusyMax => '打开 BusyMax'; + String importDuplicatesSkipped(int count) { + return '已跳过重复项:$count'; + } @override - String get agendaLoadMoreOverdue => '加载更多逾期任务'; + String importUnsupportedSets(int count) { + return '不支持的重复集:$count'; + } @override - String get agendaLoadMoreNoDate => '加载更多无日期任务'; + String importIcsFailed(String error) { + return '无法导入日历文件:$error'; + } @override - String get viewDay => '日'; + String get networkOffline => '离线'; @override - String get viewWeek => '周'; + String get networkOfflineDescription => '恢复连接后将同步更改。'; @override - String get viewMonth => '月'; + String get networkOfflineTryAgain => '您当前处于离线状态。请连接互联网后重试。'; @override - String get viewYear => '年'; + String repeatOnMonthDaysSummaryMultiple(String days) { + return '$days'; + } @override - String get viewAgenda => '日程'; + String get repeatSummarySeparator => ''; @override - String get scheduleSettings => '日程'; + String repeatMonthDayValue(String day) { + return '$day日'; + } @override - String get scheduleDisplaySettings => '日程显示'; + String repeatWeekdayListPair(String first, String second) { + return '$first和$second'; + } @override - String get scheduleDisplayHoursDescription => - '日视图和周视图最初显示此时间范围。需要时,更早或更晚的项目会扩展该范围。'; + String repeatWeekdayListStart(String first, String rest) { + return '$first、$rest'; + } @override - String get scheduleDayStartsAt => '每日开始时间'; + String repeatMonthDayListPair(String first, String second) { + return '$first、$second'; + } @override - String get scheduleDayEndsAt => '每日结束时间'; + String repeatMonthDayListStart(String first, String rest) { + return '$first、$rest'; + } @override - String get sourceCalendar => '日历'; + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } @override - String get sourceTaskList => '任务列表'; + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first和$second'; + } @override - String get createChoiceTitle => '新建'; + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first、$rest'; + } @override - String get createEventAtTime => '日程'; + String repeatYearlyMonthListPair(String first, String second) { + return '$first和$second'; + } @override - String get createTaskAtDate => '任务'; + String repeatYearlyMonthListStart(String first, String rest) { + return '$first、$rest'; + } @override - String get editEvent => '编辑日程'; + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency$month$day'; + } @override - String get eventTitle => '日程标题'; + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency$month的$days'; + } @override - String get location => '地点'; + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency$months的$day'; + } @override - String get timeSlot => '时间段'; + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency$months的$days'; + } @override - String get startDateTime => '开始日期/时间'; + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一个$days', + 'second': '第二个$days', + 'third': '第三个$days', + 'fourth': '第四个$days', + 'fifth': '第五个$days', + 'secondToLast': '倒数第二个$days', + 'last': '最后一个$days', + 'other': '$days', + }); + return '$frequency$month的$_temp0'; + } @override - String get endDateTime => '结束日期/时间'; + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一个$days', + 'second': '第二个$days', + 'third': '第三个$days', + 'fourth': '第四个$days', + 'fifth': '第五个$days', + 'secondToLast': '倒数第二个$days', + 'last': '最后一个$days', + 'other': '$days', + }); + return '$frequency$months的$_temp0'; + } +} - @override - String get doesNotRepeat => '不重复'; +/// The translations for Chinese, using the Han script (`zh_Hans`). +class AppLocalizationsZhHans extends AppLocalizationsZh { + AppLocalizationsZhHans() : super('zh_Hans'); @override - String get defaultReminder => '默认提醒'; + String get windowsSupport => '支持'; @override - String get guests => '参与者'; + String get windowsThirdPartyLicenses => '第三方许可证'; @override - String get noGuests => '没有参与者'; + String get windowsSearch => '搜索'; @override - String get attendeeRequired => 'Required'; + String get windowsStartupDisabledByUser => '用户已在 Windows 设置中禁用。'; @override - String get attendeeOptional => 'Optional'; + String get windowsStartupDisabledByPolicy => '已由 Windows 策略禁用。'; @override - String get meetingSection => 'Meeting'; + String get windowsStartupUnavailable => '从 MSIX 包安装 BusyMax 后可用。'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get windowsReminderExitNotice => + '完全退出 BusyMax 后,提醒将停止。请让应用在后台运行以接收提醒。'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get windowsProductVersionLabel => '产品版本'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get windowsPackageVersionLabel => 'Windows 包版本'; @override - String get requestResponses => 'Request responses'; + String get windowsUnpackaged => '未打包'; @override - String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + String get windowsAgendaLoadMore => '加载更多议程项目'; @override - String get hideGuestList => 'Hide guest list'; + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '星期一', + 'TU': '星期二', + 'WE': '星期三', + 'TH': '星期四', + 'FR': '星期五', + 'SA': '星期六', + 'SU': '星期日', + 'other': '$day', + }); + return '$_temp0'; + } @override - String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + String repeatOnTwoMonthDaysSummary(String first, String second) { + return '$first、$second'; + } @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency$month的$firstDay和$secondDay'; + } @override - String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency$firstMonth和$secondMonth的$day'; + } @override - String get notifyGuestsTitle => 'Notify guests?'; + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency$firstMonth和$secondMonth的$firstDay和$secondDay'; + } @override - String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency$firstMonth和$secondMonth的$days'; + } @override - String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + String get appTitle => 'BusyMax'; @override - String get sendUpdates => 'Send updates'; + String get connectGoogleAccount => + '连接 Google、Microsoft、Apple iCloud Calendar 或 Nextcloud 账户。'; @override - String get sendCancellation => 'Send cancellation'; + String get googlePermissionsConsentNotice => '在 Google 权限页面上,同时选择日历和任务权限。'; @override - String get doNotSend => 'Don’t send'; + String get googlePermissionsRequiredRetry => + '必须授予 Google 日历和 Google Tasks 权限。请重试并选中两个复选框。'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get finishSetup => '完成设置'; @override - String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + String get continueSetup => '继续'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get onboardingSetupTitle => '设置 BusyMax'; @override - String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + String get onboardingAccountsStepTitle => '连接账户'; @override - String get organizer => 'Organizer'; + String get onboardingAccountsStepDescription => + '添加所有要使用的账户。BusyMax 会同步每个账户中受支持的日历、日程、任务列表和任务。'; @override - String get yourResponse => 'Your response'; + String get onboardingPreferencesStepTitle => '选择系统设置'; @override - String get guestResponses => 'Guest responses'; + String get onboardingPreferencesStepDescription => + '打开日程前,请设置桌面行为、提醒、通知详细程度和外观。'; @override - String get respond => 'Respond'; + String get signInWithGoogle => '使用 Google 登录'; @override - String get acceptInvitation => 'Accept'; + String get signInWithMicrosoft => '使用 Microsoft 登录'; @override - String get tentativeInvitation => 'Tentative'; + String get googleTasksProvider => 'Google Tasks'; @override - String get declineInvitation => 'Decline'; + String get microsoftTodoProvider => 'Microsoft To Do'; @override - String get joinMeeting => 'Join meeting'; + String get providerNotConfigured => '尚未配置此服务。'; @override - String get responseAccepted => 'Accepted'; + String get waitingForGoogleSignIn => '正在等待 Google 登录...'; @override - String get responseTentative => 'Tentative'; + String get waitingForMicrosoftSignIn => '正在等待 Microsoft 登录...'; @override - String get responseDeclined => 'Declined'; + String get microsoftSignInNotConfigured => + '尚未配置 Microsoft 登录。请设置 MICROSOFT_OAUTH_CLIENT_ID。'; @override - String get responseNeedsAction => 'Awaiting response'; + String get cancel => '取消'; @override - String get responseNotResponded => 'Not responded'; + String get close => '关闭'; @override - String get responseOrganizer => 'Organizer'; + String get exit => '退出'; @override - String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; - } + String get options => '选项'; @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get hide => '隐藏'; @override - String get description => '说明'; + String get show => '显示'; @override - String get availabilityShowAs => '空闲状态 / 显示为'; + String get export => '导出'; @override - String get busy => '忙碌'; + String get save => '保存'; @override - String get visibility => '可见性'; + String get settings => '设置'; @override - String get defaultVisibility => '默认可见性'; + String get all => '全部'; @override - String get conference => '会议'; + String get calendarEvents => '日程'; @override - String get noConference => '无会议'; + String get calendarTasks => '任务'; @override - String get providerCalendar => '服务日历'; + String get calendar => '日历'; @override - String get formatBoldShortLabel => 'B'; + String get calendars => '日历'; @override - String get formatBoldTooltip => '粗体'; + String get newCalendar => '新建日历'; @override - String get formatItalicShortLabel => 'I'; + String get calendarColor => '日历颜色'; @override - String get formatItalicTooltip => '斜体'; + String calendarColorOption(int number) { + return '颜色 $number'; + } @override - String get formatUnderlineShortLabel => 'U'; + String get calendarManagementUnsupported => '此提供商不支持 BusyMax 中的日历管理。'; @override - String get formatUnderlineTooltip => '下划线'; + String get primaryCalendarCannotDelete => '无法删除主日历。'; @override - String reminderMinutesBefore(int minutes) { - String _temp0 = intl.Intl.pluralLogic( - minutes, - locale: localeName, - other: '$minutes 分钟前', - one: '1 分钟前', - ); - return '$_temp0'; + String calendarCreateFailed(String error) { + return '无法创建日历:$error'; } @override - String get reminderAtStart => '开始时'; + String get calendarCreatedRefreshPending => + '日历已创建,但 BusyMax 无法刷新账户。它将在下次同步后显示。'; @override - String reminderHoursBefore(int hours) { - String _temp0 = intl.Intl.pluralLogic( - hours, - locale: localeName, - other: '$hours 小时前', - one: '1 小时前', - ); - return '$_temp0'; + String calendarUpdateFailed(String error) { + return '无法更新日历:$error'; } @override - String reminderDaysBefore(int days) { - String _temp0 = intl.Intl.pluralLogic( - days, - locale: localeName, - other: '$days 天前', - one: '1 天前', - ); - return '$_temp0'; + String calendarDeleteFailed(String error) { + return '无法删除日历:$error'; } @override - String get availabilityFree => '空闲'; + String get newEvent => '新建日程'; @override - String get availabilityTentative => '暂定'; + String get refreshCalendar => '刷新日历'; @override - String get availabilityOutOfOffice => '不在办公室'; + String get openInProvider => '在服务中打开'; @override - String get availabilityWorkingElsewhere => '在其他地点办公'; + String get hideFromSchedule => '从日程中隐藏'; @override - String get visibilityDefault => '默认'; + String get showInSchedule => '在日程中显示'; @override - String get visibilityPublic => '公开'; + String get noCalendarsSynced => '尚未同步任何日历。'; @override - String get visibilityPrivate => '私密'; + String get allDay => '全天'; @override - String get visibilityConfidential => '机密'; + String moreItems(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); - @override - String get sensitivityNormal => '普通'; + return '还有 $countString 项'; + } @override - String get sensitivityPersonal => '个人'; + String get noEventsOrTasks => '没有日程或任务'; @override - String get tasks => '任务'; + String get scheduleLoading => '正在加载日程...'; @override - String get allTasks => '所有任务'; + String get scheduleUnavailable => '日程不可用'; @override - String tasksInList(String title) { - return '$title中的任务'; - } + String get scheduleNoSources => '没有可见的日历或任务列表'; @override - String get taskLists => '任务列表'; + String get scheduleNoSourcesDescription => '请在设置中选择要显示的内容,然后刷新。'; @override - String get navigation => '导航'; + String get scheduleSignInRequired => '连接账户'; @override - String get mainMenu => '主菜单'; + String get scheduleSignInDescription => '登录以同步日历和任务。'; @override - String get keyboardShortcuts => '键盘快捷键'; + String get scheduleNoSearchResults => '没有匹配的日程或任务'; @override - String get shortcutGroupGeneral => '常规'; + String get scheduleNoSearchResultsDescription => '请尝试其他搜索内容或清除当前筛选条件。'; @override - String get shortcutKeyboardShortcutsDescription => '显示快捷键参考表'; + String get refresh => '刷新'; @override - String get shortcutGroupNavigation => '导航'; + String get trayOpenBusyMax => '打开 BusyMax'; @override - String get shortcutNextPeriod => '下一时段'; + String get trayShowBusyMax => '显示 BusyMax'; @override - String get shortcutNextPeriodDescription => '在周视图中前往下一周,在月视图中前往下个月,依此类推'; + String get trayNewEvent => '新日程…'; @override - String get shortcutPreviousPeriod => '上一时段'; + String get trayNewTask => '新任务…'; @override - String get shortcutPreviousPeriodDescription => '在周视图中前往上一周,在月视图中前往上个月,依此类推'; + String get trayToday => '今天'; @override - String get shortcutJumpToToday => '跳转到今天'; + String get trayAllDay => '全天'; @override - String get shortcutGroupView => '视图'; + String get trayNow => '现在'; @override - String get shortcutDayView => '日视图'; + String get trayCalendarEvent => '日历日程'; @override - String get shortcutWeekView => '周视图'; + String get trayUntitledEvent => '无标题日程'; @override - String get shortcutMonthView => '月视图'; + String get trayNothingElseToday => '今天没有其他内容'; @override - String get shortcutYearView => '年视图'; + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '今天有 $count 个任务到期', + one: '今天有 1 个任务到期', + ); + return '$_temp0'; + } @override - String get shortcutAgendaView => '日程视图'; + String get trayOpenTodayAgenda => '打开今天的日程'; @override - String get shortcutGroupCreateAndEdit => '新建和编辑'; + String get traySyncNow => '立即同步'; @override - String get shortcutSaveItem => '保存日程或任务'; + String get traySyncing => '正在同步…'; @override - String get shortcutDeleteItem => '删除日程或任务'; + String get trayNotConnected => '未连接'; @override - String get shortcutGroupTaskEditing => '任务编辑'; + String get trayNotYetSynced => '尚未同步'; @override - String get shortcutCancelEditing => '取消编辑'; + String get trayLastSyncedJustNow => '刚刚同步'; @override - String get shortcutCancelEditingDescription => '关闭任务编辑或任务详情'; + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 分钟前同步', + one: '1 分钟前同步', + ); + return '$_temp0'; + } @override - String get aboutBusyMax => '关于 BusyMax'; + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 小时前同步', + one: '1 小时前同步', + ); + return '$_temp0'; + } @override - String get aboutBusyMaxDescription => '日历和任务'; + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 天前同步', + one: '1 天前同步', + ); + return '$_temp0'; + } @override - String get license => '许可证'; + String get traySettings => '设置'; @override - String get apacheLicenseName => 'Apache License 2.0'; + String get trayQuitBusyMax => '退出 BusyMax'; @override - String get website => '网站'; + String get agendaLoadMoreOverdue => '加载更多逾期任务'; @override - String get sourceCode => '源代码'; + String get agendaLoadMoreNoDate => '加载更多无日期任务'; @override - String get reportAnIssue => '报告问题'; + String get viewDay => '日'; @override - String get sendFeedback => '发送反馈'; + String get viewWeek => '周'; @override - String get feedbackSubmit => '提交'; + String get viewMonth => '月'; @override - String get feedbackCategory => '类别'; + String get viewYear => '年'; @override - String get feedbackSelectCategory => '选择类别'; + String get viewAgenda => '日程'; @override - String get feedbackCategoryProblem => '问题或错误'; + String get scheduleSettings => '日程'; @override - String get feedbackCategoryFeature => '功能请求'; + String get scheduleDisplaySettings => '日程显示'; @override - String get feedbackCategoryPrivacySecurity => '隐私或安全问题'; + String get scheduleDisplayHoursDescription => + '日视图和周视图最初显示此时间范围。需要时,更早或更晚的项目会扩展该范围。'; @override - String get feedbackCategoryUsability => '易用性问题'; + String get scheduleDayStartsAt => '每日开始时间'; @override - String get feedbackCategoryOther => '其他'; + String get scheduleDayEndsAt => '每日结束时间'; @override - String get feedbackSubject => '主题'; + String get sourceCalendar => '日历'; @override - String get feedbackDetailedMessage => '详细信息'; + String get sourceTaskList => '任务列表'; @override - String get feedbackReplyEmail => '用于接收回复的电子邮件地址(可选)'; + String get createChoiceTitle => '新建'; @override - String get feedbackIncludeTechnicalDetails => '包含技术详情'; + String get createEventAtTime => '日程'; @override - String get feedbackTechnicalDetailsDisclosure => - '仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、帐户数据、文件名或其他诊断信息。'; + String get createTaskAtDate => '任务'; @override - String get feedbackCategoryRequired => '请选择类别。'; + String get editEvent => '编辑日程'; @override - String get feedbackSubjectLengthError => '主题必须为 3 至 120 个字符。'; + String get eventTitle => '日程标题'; @override - String get feedbackMessageLengthError => '消息必须为 10 至 5,000 个字符。'; + String get location => '地点'; @override - String get feedbackInvalidEmail => '请输入有效的电子邮件地址。'; + String get timeSlot => '时间段'; @override - String get feedbackConnectionError => '无法连接到 BusyStack。请检查连接,然后重试。'; + String get startDateTime => '开始日期/时间'; @override - String get feedbackTimeoutError => '请求超时。您的反馈尚未清除,请重试。'; + String get endDateTime => '结束日期/时间'; @override - String get feedbackRateLimitedError => '从此网络发送的反馈过多。请稍后再试。'; + String get doesNotRepeat => '不重复'; @override - String get feedbackRejectedError => '服务器拒绝了提交。请检查各字段,然后重试。'; + String get defaultReminder => '默认提醒'; @override - String get feedbackServerError => 'BusyStack 目前无法接收您的反馈。您的反馈尚未清除,请重试。'; + String get guests => '参与者'; @override - String feedbackSuccess(String id) { - return '反馈已发送。参考编号:$id'; - } + String get noGuests => '无参与者'; @override - String get toggleSidebar => '显示或隐藏侧边栏'; + String get attendeeRequired => '必需'; @override - String get showSidebar => '显示侧边栏面板'; + String get attendeeOptional => '可选'; @override - String get hideSidebar => '隐藏侧边栏面板'; + String get meetingSection => '会议'; @override - String get accounts => '帐户'; + String get addGoogleMeet => '添加 Google Meet'; @override - String get currentAccount => '当前帐户'; + String get addTeamsMeeting => '添加 Microsoft Teams 会议'; @override - String get switchAccount => '切换帐户'; + String get onlineMeetingAdded => '已添加在线会议'; @override - String get addGoogleAccount => '添加 Google 帐户'; + String get requestResponses => '请求回复'; @override - String get addMicrosoftAccount => '添加 Microsoft 帐户'; + String get requestResponsesDescription => '要求参与者回复邀请。'; @override - String get googleProvider => 'Google'; + String get hideGuestList => '隐藏参与者列表'; @override - String get microsoftProvider => 'Microsoft'; + String get hideGuestListDescription => '参与者无法查看其他受邀者。'; @override - String get signedInAccount => '已登录'; + String get allowNewTimeProposals => '允许提出新时间'; @override - String get removeAccount => '移除帐户…'; + String get allowNewTimeProposalsDescription => '参与者可以建议其他会议时间。'; @override - String get removingAccount => '正在移除帐户…'; + String get notifyGuestsTitle => '通知参与者?'; @override - String get removeAccountDescription => '停止同步并从此设备移除此帐户的数据。'; + String get notifyGuestsSaveMessage => '此会议有参与者。保存时发送邀请或日程更新吗?'; @override - String removeAccountTitle(String account) { - return '从 BusyMax 中移除 $account?'; - } + String get notifyGuestsDeleteMessage => '此会议有参与者。删除时发送取消通知吗?'; @override - String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + String get sendUpdates => '发送更新'; @override - String get revokeGoogleAccess => '同时撤销 BusyMax 对此 Google 帐户的访问权限'; + String get sendCancellation => '发送取消通知'; @override - String get revokeGoogleAccessDescription => '重新连接之前,您需要再次授予访问权限。'; + String get doNotSend => '不发送'; @override - String get removeAccountAction => '移除帐户'; + String get microsoftNotifyGuestsSaveTitle => '保存会议?'; @override - String get removeAccountFailed => '无法完成帐户移除。请重试。'; + String get microsoftNotifyGuestsSaveMessage => 'Microsoft 将向参与者发送邀请或日程更新。'; @override - String get accountRemovedGoogleRevokeFailed => - '该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。'; + String get microsoftNotifyGuestsDeleteTitle => '删除会议?'; @override - String get newTaskList => '新建任务列表'; + String get microsoftNotifyGuestsDeleteMessage => 'Microsoft 将向参与者发送取消通知。'; @override - String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; - } + String get organizer => '组织者'; @override - String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; - } + String get yourResponse => '你的回复'; @override - String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + String get guestResponses => '参与者回复'; + + @override + String get respond => '回复'; + + @override + String get acceptInvitation => '接受'; + + @override + String get tentativeInvitation => '暂定'; + + @override + String get declineInvitation => '拒绝'; + + @override + String get joinMeeting => '加入会议'; + + @override + String get responseAccepted => '已接受'; + + @override + String get responseTentative => '暂定'; + + @override + String get responseDeclined => '已拒绝'; + + @override + String get responseNeedsAction => '等待回复'; + + @override + String get responseNotResponded => '未回复'; + + @override + String get responseOrganizer => '组织者'; + + @override + String invitationResponseFailed(String error) { + return '无法发送回复:$error'; } @override - String get signInToViewTaskLists => '登录以查看任务列表。'; + String get joinMeetingFailed => '无法打开会议链接。'; @override - String get noTaskListsSynced => '尚未同步任何任务列表。'; + String get description => '说明'; @override - String get listActions => '列表操作'; + String get availabilityShowAs => '空闲状态 / 显示为'; @override - String get rename => '重命名'; + String get busy => '忙碌'; @override - String get delete => '删除'; + String get visibility => '可见性'; @override - String get renameList => '重命名列表'; + String get defaultVisibility => '默认可见性'; @override - String get deleteList => '删除列表'; + String get conference => '会议'; @override - String get unshare => '取消共享'; + String get noConference => '无会议'; @override - String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + String get providerCalendar => '服务日历'; @override - String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + String get formatBoldShortLabel => 'B'; @override - String get builtInMicrosoftList => '内置'; + String get formatBoldTooltip => '粗体'; @override - String get builtInMicrosoftListCannotRenameDelete => - '无法重命名或删除 Microsoft To Do 内置列表。'; + String get formatItalicShortLabel => 'I'; @override - String deleteListConfirmation(String title) { - return '从 Google Tasks 中删除“$title”?'; + String get formatItalicTooltip => '斜体'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => '下划线'; + + @override + String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutesString 分钟前', + one: '1 分钟前', + ); + return '$_temp0'; } @override - String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + String get reminderAtStart => '开始时'; + + @override + String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hoursString 小时前', + one: '1 小时前', + ); + return '$_temp0'; } @override - String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$daysString 天前', + one: '1 天前', + ); + return '$_temp0'; } @override - String get deleteEvent => '删除日程'; + String get availabilityFree => '空闲'; @override - String get title => '标题'; + String get availabilityTentative => '暂定'; @override - String get create => '新建'; + String get availabilityOutOfOffice => '不在办公室'; @override - String get newTask => '新建任务'; + String get availabilityWorkingElsewhere => '在其他地点办公'; @override - String get clearCompleted => '清除已完成项'; + String get visibilityDefault => '默认'; @override - String get refreshList => '刷新列表'; + String get visibilityPublic => '公开'; @override - String get refreshAll => '全部刷新'; + String get visibilityPrivate => '私密'; @override - String get listRefreshed => '列表已刷新。'; + String get visibilityConfidential => '机密'; @override - String get allTasksRefreshed => '所有帐户均已刷新。'; + String get sensitivityNormal => '普通'; @override - String exportedFile(String path) { - return '已导出到 $path'; - } + String get sensitivityPersonal => '个人'; @override - String exportFailed(String error) { - return '导出失败:$error'; - } + String get tasks => '任务'; @override - String refreshFailed(String error) { - return '刷新失败:$error'; + String get allTasks => '所有任务'; + + @override + String tasksInList(String title) { + return '$title中的任务'; } @override - String get selectOrCreateTaskList => '请选择或创建任务列表以开始使用。'; + String get taskLists => '任务列表'; @override - String get signInToViewTasks => '登录以查看任务。'; + String get navigation => '导航'; @override - String get noTasks => '没有任务。'; + String get mainMenu => '主菜单'; @override - String get noTasksYet => '还没有任务'; + String get keyboardShortcuts => '键盘快捷键'; @override - String get noTasksYetMessage => '创建任务或刷新帐户以开始使用。'; + String get shortcutGroupGeneral => '常规'; @override - String get noTasksInList => '此列表中没有任务。'; + String get shortcutKeyboardShortcutsDescription => '显示快捷键参考表'; @override - String get overdue => '已逾期'; + String get shortcutGroupNavigation => '导航'; @override - String get today => '今天'; + String get shortcutNextPeriod => '下一时段'; @override - String get tomorrow => '明天'; + String get shortcutNextPeriodDescription => '在周视图中前往下一周,在月视图中前往下个月,依此类推'; @override - String get upcoming => '即将到期'; + String get shortcutPreviousPeriod => '上一时段'; @override - String get noDate => '无日期'; + String get shortcutPreviousPeriodDescription => '在周视图中前往上一周,在月视图中前往上个月,依此类推'; @override - String get completed => '已完成'; + String get shortcutJumpToToday => '跳转到今天'; @override - String duePrefix(String date) { - return '$date 到期'; - } + String get shortcutGroupView => '视图'; @override - String dateTimeDisplay(String date, String time) { - return '$date · $time'; - } + String get shortcutDayView => '日视图'; @override - String get taskDetails => '任务详情'; + String get shortcutWeekView => '周视图'; @override - String get editTask => '编辑任务'; + String get shortcutMonthView => '月视图'; @override - String get noTaskSelected => '未选择任务。'; + String get shortcutYearView => '年视图'; @override - String get noTaskSelectedHelper => '选择任务以查看和编辑详情。'; + String get shortcutAgendaView => '日程视图'; @override - String get taskUnavailable => '任务不可用。'; + String get shortcutGroupCreateAndEdit => '新建和编辑'; @override - String get signInToEditTasks => '登录以编辑任务。'; + String get shortcutSaveItem => '保存日程或任务'; @override - String get refreshTask => '刷新任务'; + String get shortcutDeleteItem => '删除日程或任务'; @override - String get primarySection => '主要信息'; + String get shortcutGroupTaskEditing => '任务编辑'; @override - String get statusSection => '状态'; + String get shortcutCancelEditing => '取消编辑'; @override - String get openStatus => '未完成'; + String get shortcutCancelEditingDescription => '关闭任务编辑或任务详情'; @override - String get doneStatus => '已完成'; + String get aboutBusyMax => '关于 BusyMax'; @override - String get taskStatus => 'Status'; + String get aboutBusyMaxDescription => '日历和任务'; @override - String get taskStatusNone => 'No status'; + String get license => '许可证'; @override - String get taskStatusNeedsAction => '需要操作'; + String get apacheLicenseName => 'Apache License 2.0'; @override - String get taskStatusInProcess => '处理中'; + String get website => '网站'; @override - String get taskStatusCompleted => '完成'; + String get sourceCode => '源代码'; @override - String get taskStatusCancelled => 'Cancelled'; + String get reportAnIssue => '报告问题'; @override - String completionPercent(int percent) { - return '$percent% completed'; - } + String get sendFeedback => '发送反馈'; + + @override + String get feedbackSubmit => '提交'; + + @override + String get feedbackCategory => '类别'; + + @override + String get feedbackSelectCategory => '选择类别'; + + @override + String get feedbackCategoryProblem => '问题或错误'; + + @override + String get feedbackCategoryFeature => '功能请求'; + + @override + String get feedbackCategoryPrivacySecurity => '隐私或安全问题'; + + @override + String get feedbackCategoryUsability => '易用性问题'; + + @override + String get feedbackCategoryOther => '其他'; + + @override + String get feedbackSubject => '主题'; + + @override + String get feedbackDetailedMessage => '详细信息'; + + @override + String get feedbackReplyEmail => '用于接收回复的电子邮件地址(可选)'; + + @override + String get feedbackIncludeTechnicalDetails => '包含技术详情'; + + @override + String get feedbackTechnicalDetailsDisclosure => + '仅添加您的操作系统名称和版本以及应用区域设置。不包含日志、账户数据、文件名或其他诊断信息。'; + + @override + String get feedbackCategoryRequired => '请选择类别。'; + + @override + String get feedbackSubjectLengthError => '主题必须为 3 至 120 个字符。'; + + @override + String get feedbackMessageLengthError => '消息必须为 10 至 5,000 个字符。'; + + @override + String get feedbackInvalidEmail => '请输入有效的电子邮件地址。'; + + @override + String get feedbackConnectionError => '无法连接到 BusyStack。请检查连接,然后重试。'; + + @override + String get feedbackTimeoutError => '请求超时。您的反馈尚未清除,请重试。'; + + @override + String get feedbackRateLimitedError => '从此网络发送的反馈过多。请稍后再试。'; + + @override + String get feedbackRejectedError => '服务器拒绝了提交。请检查各字段,然后重试。'; + + @override + String get feedbackServerError => 'BusyStack 目前无法接收您的反馈。您的反馈尚未清除,请重试。'; + + @override + String feedbackSuccess(String id) { + return '反馈已发送。参考编号:$id'; + } + + @override + String get toggleSidebar => '显示或隐藏侧边栏'; + + @override + String get showSidebar => '显示侧边栏面板'; + + @override + String get hideSidebar => '隐藏侧边栏面板'; + + @override + String get accounts => '账户'; + + @override + String get currentAccount => '当前账户'; + + @override + String get switchAccount => '切换账户'; + + @override + String get addGoogleAccount => '添加 Google 账户'; + + @override + String get addMicrosoftAccount => '添加 Microsoft 账户'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => '已登录'; + + @override + String get removeAccount => '移除账户…'; + + @override + String get removingAccount => '正在移除账户…'; + + @override + String get removeAccountDescription => '停止同步并从此设备移除此账户的数据。'; + + @override + String removeAccountTitle(String account) { + return '从 BusyMax 中移除 $account?'; + } + + @override + String get removeAccountConfirmation => + '这会从此设备删除缓存的任务、日历、日程、提醒和待处理的离线更改。未同步的更改将会丢失。提供商中的日历、日程、任务列表和任务副本不会被删除。'; + + @override + String get revokeGoogleAccess => '同时撤销 BusyMax 对此 Google 账户的访问权限'; + + @override + String get revokeGoogleAccessDescription => '重新连接之前,您需要再次授予访问权限。'; + + @override + String get removeAccountAction => '移除账户'; + + @override + String get removeAccountFailed => '无法完成账户移除。请重试。'; + + @override + String get accountRemovedGoogleRevokeFailed => + '该账户已从此设备移除,但无法撤销 BusyMax 对您的 Google 账户的访问权限。您可以在 Google 账户中手动撤销该权限。'; + + @override + String get newTaskList => '新任务列表'; + + @override + String taskListCreateFailed(String error) { + return '无法创建任务列表:$error'; + } + + @override + String taskListRenameFailed(String error) { + return '无法重命名任务列表:$error'; + } + + @override + String taskListDeleteFailed(String error) { + return '无法删除任务列表:$error'; + } + + @override + String get signInToViewTaskLists => '登录以查看任务列表。'; + + @override + String get noTaskListsSynced => '尚未同步任何任务列表。'; + + @override + String get listActions => '列表操作'; + + @override + String get rename => '重命名'; + + @override + String get delete => '删除'; + + @override + String get renameList => '重命名列表'; + + @override + String get deleteList => '删除列表'; + + @override + String get unshare => '取消共享'; + + @override + String get readOnlyTaskListCannotRename => '此任务列表为只读,无法重命名。'; + + @override + String get taskListCannotDelete => '使用当前权限无法删除此任务列表。'; + + @override + String get builtInMicrosoftList => '内置'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + '无法重命名或删除 Microsoft To Do 内置列表。'; + + @override + String deleteListConfirmation(String title) { + return '要从 Google Tasks 中删除“$title”吗?'; + } + + @override + String deleteTaskListConfirmation(String title) { + return '要删除“$title”及其所有任务吗?'; + } + + @override + String unshareTaskListConfirmation(String title) { + return '要取消此账户对“$title”的共享吗?'; + } + + @override + String get deleteEvent => '删除日程'; + + @override + String get title => '标题'; + + @override + String get create => '新建'; + + @override + String get newTask => '新建任务'; + + @override + String get clearCompleted => '清除已完成项'; + + @override + String get refreshList => '刷新列表'; + + @override + String get refreshAll => '全部刷新'; + + @override + String get listRefreshed => '列表已刷新。'; + + @override + String get allTasksRefreshed => '所有账户均已刷新。'; + + @override + String exportedFile(String path) { + return '已导出到 $path'; + } + + @override + String exportFailed(String error) { + return '导出失败:$error'; + } + + @override + String refreshFailed(String error) { + return '刷新失败:$error'; + } + + @override + String get selectOrCreateTaskList => '请选择或创建任务列表以开始使用。'; + + @override + String get signInToViewTasks => '登录以查看任务。'; + + @override + String get noTasks => '没有任务。'; + + @override + String get noTasksYet => '还没有任务'; + + @override + String get noTasksYetMessage => '创建任务或刷新账户以开始使用。'; + + @override + String get noTasksInList => '此列表中没有任务。'; + + @override + String get overdue => '已逾期'; + + @override + String get today => '今天'; + + @override + String get tomorrow => '明天'; + + @override + String get upcoming => '即将到期'; + + @override + String get noDate => '无日期'; + + @override + String get completed => '已完成'; + + @override + String duePrefix(String date) { + return '$date 到期'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => '任务详情'; + + @override + String get editTask => '编辑任务'; + + @override + String get noTaskSelected => '未选择任务。'; + + @override + String get noTaskSelectedHelper => '选择任务以查看和编辑详情。'; + + @override + String get taskUnavailable => '任务不可用。'; + + @override + String get signInToEditTasks => '登录以编辑任务。'; + + @override + String get refreshTask => '刷新任务'; + + @override + String get primarySection => '主要信息'; + + @override + String get statusSection => '状态'; + + @override + String get openStatus => '未完成'; + + @override + String get doneStatus => '已完成'; + + @override + String get taskStatus => '状态'; + + @override + String get taskStatusNone => '无状态'; + + @override + String get taskStatusNeedsAction => '需要操作'; + + @override + String get taskStatusInProcess => '进行中'; + + @override + String get taskStatusCompleted => '已完成'; + + @override + String get taskStatusCancelled => '已取消'; + + @override + String completionPercent(int percent) { + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '已完成 $percentString%'; + } + + @override + String get completionDate => '完成日期'; + + @override + String get priority => '优先级'; + + @override + String get priorityNone => '无优先级'; + + @override + String priorityHighValue(int priority) { + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '优先级 $priorityString · 高'; + } + + @override + String priorityMediumValue(int priority) { + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '优先级 $priorityString · 中'; + } + + @override + String priorityLowValue(int priority) { + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '优先级 $priorityString · 低'; + } + + @override + String get taskUrl => '任务 URL'; + + @override + String get invalidTaskUrl => '请输入包含方案的绝对 URL。'; + + @override + String get classification => '分类'; + + @override + String get classificationPublic => '共享时显示完整任务'; + + @override + String get classificationConfidential => '共享时仅显示忙碌状态'; + + @override + String get classificationPrivate => '共享时隐藏此任务'; + + @override + String get pinTask => '固定任务'; + + @override + String get notes => '备注'; + + @override + String get dueDate => '截止日期'; + + @override + String get clearDueDate => '清除截止日期'; + + @override + String get dueTime => '截止时间'; + + @override + String get startDate => '开始日期'; + + @override + String get startTime => '开始时间'; + + @override + String get endDate => '结束日期'; + + @override + String get endTime => '结束时间'; + + @override + String get reminderDate => '提醒日期'; + + @override + String get reminderTime => '提醒时间'; + + @override + String get reminder => '提醒'; + + @override + String get addReminder => '添加提醒'; + + @override + String get reminders => '提醒'; + + @override + String get noReminders => '无提醒'; + + @override + String get editReminder => '编辑提醒'; + + @override + String get beforeTaskStarts => '任务开始前'; + + @override + String get beforeTaskDue => '任务到期前'; + + @override + String get afterTaskStarts => '任务开始后'; + + @override + String get afterTaskDue => '任务到期后'; + + @override + String get relativeToTaskStart => '相对于任务开始日期'; + + @override + String get relativeToTaskDue => '相对于任务到期日期'; + + @override + String get reminderTimeOfDay => '时间'; + + @override + String get absoluteReminder => '在指定日期和时间'; + + @override + String get reminderAmount => '数量'; + + @override + String get reminderUnit => '单位'; + + @override + String get reminderUnitSeconds => '秒'; + + @override + String get reminderUnitMinutes => '分钟'; + + @override + String get reminderUnitHours => '小时'; + + @override + String get reminderUnitDays => '天'; + + @override + String get reminderUnitWeeks => '周'; + + @override + String get reminderAtTaskStart => '任务开始时'; + + @override + String get reminderAtTaskDue => '任务到期时'; + + @override + String get unsupportedReminder => '此提醒类型会保留,但无法编辑其时间。'; + + @override + String get relatedRemindersTitle => '保留相关提醒?'; + + @override + String relatedRemindersDescription(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '此日期有 $countString 个相关提醒。要保留它们当前的日期和时间吗?'; + } + + @override + String get discardRelatedReminders => '舍弃提醒'; + + @override + String get keepRelatedReminders => '保留提醒'; + + @override + String get addGuest => '添加参与者'; + + @override + String get addGuestEmail => '添加参与者电子邮件'; + + @override + String get removeReminder => '移除提醒'; + + @override + String get off => '关闭'; + + @override + String get repeat => '重复'; + + @override + String get repeatNone => '不重复'; + + @override + String get noneValue => '无'; + + @override + String get repeatDaily => '每天'; + + @override + String get repeatWeekly => '每周'; + + @override + String get repeatMonthly => '每月'; + + @override + String get repeatYearly => '每年'; + + @override + String get repeatEvery => '重复间隔'; + + @override + String get repeatOn => '重复日期'; + + @override + String get repeatEnd => '结束重复'; + + @override + String get repeatNever => '从不'; + + @override + String get repeatUntil => '指定日期'; + + @override + String get repeatAfter => '指定次数后'; + + @override + String get repeatCount => '重复次数'; + + @override + String get repeatDayOfMonth => '每月日期'; + + @override + String get repeatMonths => '月份'; + + @override + String get repeatOrdinal => '星期位置'; + + @override + String get repeatSpecificDays => '特定日期'; + + @override + String get repeatFirst => '第一'; + + @override + String get repeatSecond => '第二'; + + @override + String get repeatThird => '第三'; + + @override + String get repeatFourth => '第四'; + + @override + String get repeatFifth => '第五'; + + @override + String get repeatSecondToLast => '倒数第二'; + + @override + String get repeatLast => '最后'; + + @override + String get repeatAnyDay => '日期'; + + @override + String get repeatWeekday => '工作日'; + + @override + String get repeatWeekendDay => '周末'; + + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '星期一', + 'TU': '星期二', + 'WE': '星期三', + 'TH': '星期四', + 'FR': '星期五', + 'SA': '星期六', + 'SU': '星期日', + 'day': '日期', + 'weekday': '工作日', + 'weekend': '周末', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatEveryDays(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 天'; + } + + @override + String repeatEveryWeeks(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 周'; + } + + @override + String repeatEveryMonths(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 个月'; + } + + @override + String repeatEveryYears(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 年'; + } + + @override + String repeatOnDaysSummary(String days) { + return '在 $days'; + } + + @override + String repeatOnMonthDaysSummary(String days) { + return '$days'; + } + + @override + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一个$days', + 'second': '第二个$days', + 'third': '第三个$days', + 'fourth': '第四个$days', + 'fifth': '第五个$days', + 'secondToLast': '倒数第二个$days', + 'last': '最后一个$days', + 'other': '$days', + }); + return '$_temp0'; + } + + @override + String repeatInMonthsSummary(String months) { + return '在 $months'; + } + + @override + String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '重复 $countString 次', + ); + return '$_temp0'; + } + + @override + String repeatUntilSummary(String date) { + return '直到 $date'; + } + + @override + String get unsupportedRecurrencePreserved => '此重复规则使用了编辑器不会更改的选项。'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return '此重复规则不能用于 $provider。'; + } + + @override + String get importance => '重要性'; + + @override + String get importanceLow => '低'; + + @override + String get importanceNormal => '普通'; + + @override + String get importanceHigh => '高'; + + @override + String get categories => '类别'; + + @override + String get scheduleSection => '日程'; @override - String get completionDate => 'Completion date'; + String get dueGroup => '截止'; @override - String get priority => '优先级'; + String get startGroup => '开始'; @override - String get priorityNone => 'No priority'; + String get reminderGroup => '提醒'; @override - String priorityHighValue(int priority) { - return 'Priority $priority · High'; - } + String get organizationSection => '整理'; @override - String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; - } + String get actionsSection => '操作'; @override - String priorityLowValue(int priority) { - return 'Priority $priority · Low'; - } + String get advancedSection => '高级'; @override - String get taskUrl => 'URL'; + String get addCategory => '添加类别'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get list => '列表'; @override - String get classification => 'Classification'; + String get microsoftMoveUnsupported => '此版本不支持在 Microsoft To Do 账户的列表之间移动任务。'; @override - String get classificationPublic => 'When shared, show the full task'; + String get createSubtask => '创建子任务'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get subtasks => '子任务'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get duplicateTask => '复制任务'; @override - String get pinTask => 'Pin task'; + String get taskDuplicated => '任务已复制。'; @override - String get notes => '备注'; + String taskDuplicateFailed(String error) { + return '无法复制任务:$error'; + } @override - String get dueDate => '截止日期'; + String get hideSubtasks => '隐藏子任务'; @override - String get clearDueDate => '清除截止日期'; + String get hideClosedSubtasks => '隐藏已关闭的子任务'; @override - String get dueTime => '截止时间'; + String get moveToTop => '移到顶部'; @override - String get startDate => '开始日期'; + String get deleteTask => '删除任务'; @override - String get startTime => '开始时间'; + String get newSubtask => '新建子任务'; @override - String get endDate => '结束日期'; + String deleteTaskConfirmation(String title) { + return '删除“$title”?'; + } @override - String get endTime => '结束时间'; + String get metadata => '元数据'; @override - String get reminderDate => '提醒日期'; + String get id => 'ID'; @override - String get reminderTime => '提醒时间'; + String get etag => 'ETag'; @override - String get reminder => '提醒'; + String get updated => '更新时间'; @override - String get addReminder => '添加提醒'; + String get parent => '父任务'; @override - String get reminders => 'Reminders'; + String get position => '位置'; @override - String get noReminders => '无提醒'; + String get webLink => '网页链接'; @override - String get editReminder => 'Edit reminder'; + String get assignment => '分配'; @override - String get beforeTaskStarts => '任务开始前'; + String get localState => '本地状态'; @override - String get beforeTaskDue => '任务截止前'; + String get pendingSync => '等待同步'; @override - String get afterTaskStarts => 'After the task starts'; + String get synced => '已同步'; @override - String get afterTaskDue => 'After the task is due'; + String get account => '账户'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get sync => '同步'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get forceFullResync => '强制完全重新同步'; @override - String get reminderTimeOfDay => 'Time of day'; + String get forceFullResyncDescription => + '从每个已连接的账号中重新完整加载所有数据。请仅在排查同步问题时使用此功能。'; @override - String get absoluteReminder => 'At a date and time'; + String get runInBackgroundWhenClosed => '窗口关闭后继续在后台运行'; @override - String get reminderAmount => 'Amount'; + String get showTrayIcon => '显示托盘图标'; @override - String get reminderUnit => 'Unit'; + String get startMinimizedToTray => '启动时最小化到托盘'; @override - String get reminderUnitSeconds => 'Seconds'; + String get launchAtLogin => '登录时启动'; @override - String get reminderUnitMinutes => 'Minutes'; + String get launchAtLoginDescription => '在后台启动 BusyMax,以便登录后提醒能够正常工作。'; @override - String get reminderUnitHours => 'Hours'; + String get launchAtLoginFailed => '无法更新登录时启动设置。'; @override - String get reminderUnitDays => 'Days'; + String get requiresTrayIcon => '需要托盘图标。'; @override - String get reminderUnitWeeks => 'Weeks'; + String get syncComplete => '同步完成。'; @override - String get reminderAtTaskStart => 'At the task start'; + String syncFailed(String error) { + return '同步失败:$error'; + } @override - String get reminderAtTaskDue => 'At the task due time'; + String get notifySyncFailures => '同步失败通知'; @override - String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + String get notifyConflicts => '冲突通知'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get notifyDueToday => '今天到期任务通知'; @override - String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; - } + String get eventReminders => '日程提醒'; @override - String get discardRelatedReminders => '舍弃提醒'; + String get onState => '开启'; @override - String get keepRelatedReminders => '保留提醒'; + String get taskReminders => '任务提醒'; @override - String get addGuest => '添加参与者'; + String get notificationDetailLevel => '通知详细程度'; @override - String get addGuestEmail => '添加参与者电子邮件'; + String get notificationDetailPrivate => '私密'; @override - String get removeReminder => '移除提醒'; + String get notificationDetailNormal => '普通'; @override - String get off => '关闭'; + String get quietHours => '免打扰时段'; @override - String get repeat => '重复'; + String get quietHoursDescription => '在此时段暂停通知。'; @override - String get repeatNone => '不重复'; + String get quietHoursStart => '免打扰开始时间'; @override - String get noneValue => '无'; + String get quietHoursEnd => '免打扰结束时间'; @override - String get repeatDaily => '每天'; + String get notifications => '通知'; @override - String get repeatWeekly => '每周'; + String get windowsNotificationsUnavailable => 'Windows 通知不可用'; @override - String get repeatMonthly => '每月'; + String get windowsNotificationsUnpackaged => + '此未打包的开发版本无法使用 Windows 通知。请安装测试签名的 MSIX 以测试提醒。'; @override - String get repeatYearly => '每年'; + String get windowsNotificationsInstalledFailure => + 'BusyMax 无法初始化 Windows 通知。在解决此安装问题之前,提醒不会显示。'; @override - String get repeatEvery => '重复每'; + String get appearance => '外观'; @override - String get repeatOn => 'Repeat on'; + String get theme => '主题'; @override - String get repeatEnd => '结束重复'; + String get themeSystem => '系统'; @override - String get repeatNever => 'Never'; + String get settingsSystem => '系统'; @override - String get repeatUntil => 'On date'; + String get themeLight => '浅色'; @override - String get repeatAfter => 'After a number of occurrences'; + String get themeDark => '深色'; @override - String get repeatCount => 'Occurrences'; + String get themeFamily => '主题系列'; @override - String get repeatDayOfMonth => 'Days of month'; + String get themeFamilyYaru => 'Ubuntu 原生主题(Yaru)'; @override - String get repeatMonths => 'Months'; + String get localization => '语言和区域'; @override - String get repeatOrdinal => 'Weekday position'; + String get currentLocale => '当前区域设置'; @override - String get repeatSpecificDays => 'Specific days'; + String get privacy => '隐私'; @override - String get repeatFirst => 'First'; + String get redactTaskContentInDiagnostics => '在诊断信息中隐藏任务内容'; @override - String get repeatSecond => 'Second'; + String get developerDiagnostics => '开发者诊断'; @override - String get repeatThird => 'Third'; + String get diagnostics => '诊断'; @override - String get repeatFourth => 'Fourth'; + String get apiInspectorDisabled => '显示 API 检查器'; @override - String get repeatFifth => 'Fifth'; + String get googleTasksApi => 'Google Tasks API'; @override - String get repeatSecondToLast => 'Second to last'; + String discoveryRevision(String revision) { + return 'Discovery 修订版:$revision'; + } @override - String get repeatLast => 'Last'; + String get implementedMethods => '已实现的方法'; @override - String get repeatAnyDay => 'Day'; + String get supportsTasksScopes => '支持 tasks 和 tasks.readonly 权限范围'; @override - String get repeatWeekday => 'Weekday'; + String get requiresTasksScope => '需要 tasks 权限范围'; @override - String get repeatWeekendDay => 'Weekend day'; + String get blockedPendingOperations => '被阻止的待处理操作'; @override - String repeatEveryDays(int count) { - return 'Every $count days'; - } + String get signInToInspectPendingOperations => '登录以检查待处理操作。'; @override - String repeatEveryWeeks(int count) { - return 'Every $count weeks'; - } + String get noBlockedPendingOperations => '没有被阻止的待处理操作。'; @override - String repeatEveryMonths(int count) { - return 'Every $count months'; - } + String get operationActions => '操作选项'; @override - String repeatEveryYears(int count) { - return 'Every $count years'; + String pendingOpListId(String id) { + return '列表=$id'; } @override - String repeatOnDaysSummary(String days) { - return '在 $days'; + String pendingOpTaskId(String id) { + return '任务=$id'; } @override - String repeatOnMonthDaysSummary(String days) { - return '在第 $days 天'; - } + String pendingOpAttempts(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); - @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return '尝试次数=$countString'; } @override - String repeatInMonthsSummary(String months) { - return '在 $months'; - } + String get retry => '重试'; @override - String repeatTimesSummary(int count) { - return '$count次'; - } + String get discard => '舍弃'; @override - String repeatUntilSummary(String date) { - return '至 $date'; - } + String get discardChangesAction => '舍弃'; @override - String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + String get discardChanges => '舍弃更改?'; @override - String get importance => '重要性'; + String get discardChangesConfirmation => '这将舍弃对此任务所做的未保存编辑。'; @override - String get importanceLow => '低'; + String get retryCompleted => '重试完成。'; @override - String get importanceNormal => '普通'; + String get discardPendingOperation => '舍弃待处理操作?'; @override - String get importanceHigh => '高'; + String get discardPendingOperationConfirmation => + '这将移除被阻止的本地操作。下次同步时将从 Google Tasks 刷新数据。'; @override - String get categories => '类别'; + String get pendingOperationDiscarded => '已舍弃待处理操作。'; @override - String get scheduleSection => '日程'; + String get syncFailureNotificationTitle => 'BusyMax 同步失败'; @override - String get dueGroup => '截止'; + String syncFailureNotificationBody(String message) { + return '后台同步失败。$message'; + } @override - String get startGroup => '开始'; + String get conflictNotificationTitle => 'BusyMax 同步冲突'; @override - String get reminderGroup => '提醒'; + String conflictNotificationBody(String summary) { + return '一项待处理的本地更改被阻止。$summary'; + } @override - String get organizationSection => '整理'; + String get dueTodayNotificationTitle => '今天到期的任务'; @override - String get actionsSection => '操作'; + String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '今天有 $countString 项任务到期。', + one: '今天有 1 项任务到期。', + ); + return '$_temp0'; + } @override - String get advancedSection => '高级'; + String get eventReminderNotificationTitle => '日程提醒'; @override - String get addCategory => '添加类别'; + String get taskReminderNotificationTitle => '任务提醒'; @override - String get list => '列表'; + String get eventReminderNotificationBody => '日程即将开始。'; @override - String get microsoftMoveUnsupported => '此版本不支持在 Microsoft To Do 帐户的列表之间移动任务。'; + String get taskReminderNotificationBody => '任务即将到期。'; @override - String get createSubtask => '创建子任务'; + String get notificationOpenAction => '打开'; @override - String get subtasks => '子任务'; + String get notificationSnoozeAction => '10 分钟后提醒'; @override - String get duplicateTask => '复制任务'; + String get notificationDismissAction => '关闭'; @override - String get taskDuplicated => 'Task duplicated.'; + String get notificationDetailsHidden => '根据隐私设置,详细信息已隐藏。'; @override - String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; - } + String get previousMonth => '上个月'; @override - String get hideSubtasks => '隐藏子任务'; + String get nextMonth => '下个月'; @override - String get hideClosedSubtasks => '隐藏关闭的子任务'; + String get openMonthView => '打开月视图'; @override - String get moveToTop => '移到顶部'; + String get previousYear => '上一年'; @override - String get deleteTask => '删除任务'; + String get nextYear => '下一年'; @override - String get newSubtask => '新建子任务'; + String get openYearView => '打开年视图'; @override - String deleteTaskConfirmation(String title) { - return '删除“$title”?'; + String weekNumberTooltip(int number) { + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return '第 $numberString 周'; } @override - String get metadata => '元数据'; + String get resizeAllDayPanel => '调整全天面板的大小'; @override - String get id => 'ID'; + String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString 项', + one: '1 项', + ); + return '$_temp0'; + } @override - String get etag => 'ETag'; + String get readOnlyCalendar => '此日历为只读。'; @override - String get updated => '更新时间'; + String get selectTimeZone => '选择时区'; @override - String get parent => '父任务'; + String get searchLocations => '搜索地点'; @override - String get position => '位置'; + String get noLocationsFound => '未找到地点'; @override - String get webLink => '网页链接'; + String get requiredField => '此字段为必填项。'; @override - String get assignment => '分配'; + String get providerConnectionDescription => '连接以下任一提供商中的日历和任务。'; @override - String get localState => '本地状态'; + String get appleICloudProvider => 'Apple iCloud 日历'; @override - String get pendingSync => '等待同步'; + String get nextcloudProvider => 'Nextcloud'; @override - String get synced => '已同步'; + String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get account => '帐户'; + String get nextcloudTasksProvider => 'Nextcloud 任务'; @override - String get sync => '同步'; + String get addAppleICloudAccount => '添加 Apple iCloud 日历账户'; @override - String get manualFullSync => '手动完整同步'; + String get addNextcloudAccount => '添加 Nextcloud 账户'; @override - String get runInBackgroundWhenClosed => '窗口关闭后继续在后台运行'; + String get waitingForAppleICloud => '正在连接 Apple iCloud…'; @override - String get showTrayIcon => '显示托盘图标'; + String get waitingForNextcloud => '正在等待 Nextcloud 授权…'; @override - String get startMinimizedToTray => '启动时最小化到托盘'; + String get connectAppleICloudTitle => '连接 Apple iCloud 日历'; @override - String get launchAtLogin => '登录时启动'; + String get appleAccountEmail => 'Apple 账户电子邮件'; @override - String get launchAtLoginDescription => '在后台启动 BusyMax,以便登录后提醒能够正常工作。'; + String get appleAppSpecificPassword => 'App 专用密码'; @override - String get launchAtLoginFailed => '无法更新登录时启动设置。'; + String get appleAppSpecificPasswordHelp => '为 Apple 账户启用双重认证后,创建 App 专用密码。'; @override - String get requiresTrayIcon => '需要托盘图标。'; + String get appleAppSpecificPasswordResetWarning => + '重置 Apple 账户密码会撤销 App 专用密码。'; @override - String get syncComplete => '同步完成。'; + String get connectNextcloudTitle => '连接 Nextcloud'; @override - String syncFailed(String error) { - return '同步失败:$error'; - } + String get nextcloudServerUrl => 'Nextcloud 服务器或 CalDAV 地址'; @override - String get notifySyncFailures => '同步失败通知'; + String get nextcloudServerUrlHelp => + '输入 Nextcloud 服务器 URL,或粘贴从 Nextcloud 复制的主要 CalDAV 地址。'; @override - String get notifyConflicts => '冲突通知'; + String get nextcloudBrowserAuthorizationHelp => + 'BusyMax 将打开浏览器。请在那里批准访问,然后返回 BusyMax。'; @override - String get notifyDueToday => '今天到期任务通知'; + String get connectAccountAction => '连接'; @override - String get eventReminders => '日程提醒'; + String get cancelAccountConnection => '取消连接'; @override - String get taskReminders => '任务提醒'; + String get nextcloudAccountRemovedRevokeFailed => + '账户已在本地移除,但无法撤销 Nextcloud App 密码。'; @override - String get notificationDetailLevel => '通知详细程度'; + String get davCachedOfflineNotice => '日历和任务数据会缓存在本地,以供离线使用。'; @override - String get notificationDetailPrivate => '私密'; + String get davReauthenticationRequired => '重新连接此账户以恢复同步。'; @override - String get notificationDetailNormal => '普通'; + String get davTemporarilyUnavailable => '此账户暂时不可用。'; @override - String get quietHours => '免打扰时段'; + String get davPermissionChanged => '服务器权限已更改。待处理的编辑已暂停。'; @override - String get quietHoursDescription => '在此时段暂停通知。'; + String get davUnsupportedServer => '不支持此服务器或提供商配置。'; @override - String get quietHoursStart => '免打扰开始时间'; + String get collectionSettings => '日历和任务列表'; @override - String get quietHoursEnd => '免打扰结束时间'; + String get calendarContent => '日历日程'; @override - String get notifications => '通知'; + String get taskContent => '任务'; @override - String get appearance => '外观'; + String get readOnlySharedCollection => '只读'; @override - String get theme => '主题'; + String get pendingLocally => '本地待处理'; @override - String get themeSystem => '系统'; + String get conflictBlocked => '因冲突而阻止'; @override - String get themeLight => '浅色'; + String get authenticationBlocked => '重新连接前阻止'; @override - String get themeDark => '深色'; + String get operationFailed => '操作失败'; @override - String get themeFamily => '主题系列'; + String get keepServerVersion => '保留服务器版本'; @override - String get themeFamilyYaru => 'Ubuntu 原生主题(Yaru)'; + String get reapplyLocalChange => '查看并重新应用本地更改'; @override - String get localization => '语言和区域'; + String get duplicateLocalItem => '复制为新项目'; @override - String get currentLocale => '当前区域设置'; + String get davConnectionState => '连接状态'; @override - String get privacy => '隐私'; + String get davConnected => '已连接'; @override - String get redactTaskContentInDiagnostics => '在诊断信息中隐藏任务内容'; + String get davConnecting => '正在连接…'; @override - String get developerDiagnostics => '开发者诊断'; + String get davSignedOut => '已退出登录'; @override - String get diagnostics => '诊断'; + String davLastSuccessfulSync(String time) { + return '上次成功同步:$time'; + } @override - String get apiInspectorDisabled => '显示 API 检查器'; + String get davNeverSynced => '尚未同步'; @override - String get googleTasksApi => 'Google Tasks API'; + String get refreshCollections => '刷新日历和任务列表'; @override - String discoveryRevision(String revision) { - return 'Discovery 修订版:$revision'; + String nextcloudServerHost(String host) { + return '服务器:$host'; } @override - String get implementedMethods => '已实现的方法'; + String get collectionSupportsEvents => '日程日历'; @override - String get supportsTasksScopes => '支持 tasks 和 tasks.readonly 权限范围'; + String get collectionSupportsTasks => '任务列表'; @override - String get requiresTasksScope => '需要 tasks 权限范围'; + String get collectionSupportsEventsAndTasks => '日程和任务'; @override - String get blockedPendingOperations => '被阻止的待处理操作'; + String get writableCollection => '可写'; @override - String get signInToInspectPendingOperations => '登录以检查待处理操作。'; + String get sharedCollection => '已共享'; @override - String get noBlockedPendingOperations => '没有被阻止的待处理操作。'; + String collectionLastSynced(String time) { + return '上次同步:$time'; + } @override - String get operationActions => '操作选项'; + String collectionSyncError(String code) { + return '同步问题:$code'; + } @override - String pendingOpListId(String id) { - return '列表=$id'; - } + String get syncConflicts => '同步冲突'; @override - String pendingOpTaskId(String id) { - return '任务=$id'; + String remoteChangedAt(String time) { + return '服务器更改:$time'; } @override - String pendingOpAttempts(int count) { - return '尝试次数=$count'; + String localPendingEdit(String summary) { + return '本地编辑:$summary'; } @override - String get retry => '重试'; + String get conflictResolutionFailed => '无法解决冲突。'; @override - String get discard => '舍弃'; + String get recurringEventScope => '重复日程范围'; @override - String get discardChangesAction => '舍弃'; + String get entireSeries => '整个系列'; @override - String get discardChanges => '舍弃更改?'; + String get singleOccurrence => '此事件'; @override - String get discardChangesConfirmation => '这将舍弃对此任务所做的未保存编辑。'; + String get thisAndFollowingEvents => '此事件及后续事件'; @override - String get retryCompleted => '重试完成。'; + String get thisAndFutureUnavailable => '此提供商不支持。'; @override - String get discardPendingOperation => '舍弃待处理操作?'; + String get thisAndFutureMoveUnavailable => '无法安全移动此日程及后续日程。请选择此日程或整个系列。'; @override - String get discardPendingOperationConfirmation => - '这将移除被阻止的本地操作。下次同步时将从 Google Tasks 刷新数据。'; + String get entireSeriesMoveUnavailable => '本地没有可用的重复规则。请仅移动此日程。'; @override - String get pendingOperationDiscarded => '已舍弃待处理操作。'; + String get copyEventAndDeleteOriginal => '复制日程并删除原日程?'; @override - String get syncFailureNotificationTitle => 'BusyMax 同步失败'; + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax 无法将此日程直接从 $source 移动到 $destination。应用会先创建副本,并且仅在复制成功后删除原日程。日程 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。'; + } @override - String syncFailureNotificationBody(String message) { - return '后台同步失败。$message'; - } + String get copyAndDelete => '复制并删除'; @override - String get conflictNotificationTitle => 'BusyMax 同步冲突'; + String get chooseRecurringEventScope => '选择此更改适用于整个系列、仅此日程,还是此日程及后续日程。'; @override - String conflictNotificationBody(String summary) { - return '一项待处理的本地更改被阻止。$summary'; - } + String get taskDueBeforeStart => '截止时间不能早于开始时间。'; @override - String get dueTodayNotificationTitle => '今天到期的任务'; + String get taskStartDueTimeModeMismatch => '请同时设置开始和截止时间,或将任务设为全天。'; @override - String dueTodayNotificationBody(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '今天有 $count 项任务到期。', - one: '今天有 1 项任务到期。', - ); - return '$_temp0'; + String deleteCalendarConfirmation(String title) { + return '删除“$title”?'; } @override - String get eventReminderNotificationTitle => '日程提醒'; + String get setCustomCalendarName => '设置自定义名称'; @override - String get taskReminderNotificationTitle => '任务提醒'; + String get setAction => '设置'; @override - String get eventReminderNotificationBody => '日程即将开始。'; + String get removeFromMyCalendars => '从“我的日历”中移除'; @override - String get taskReminderNotificationBody => '任务即将到期。'; + String get removeAction => '移除'; @override - String get notificationOpenAction => '打开'; + String removeCalendarConfirmation(String title) { + return '要从您的 Google 日历列表中移除“$title”吗?共享日历及其日程不会被删除。'; + } @override - String get notificationSnoozeAction => '10 分钟后提醒'; + String get calendarCannotRemove => '无法从此账户删除或移除此日历。'; @override - String get notificationDismissAction => '忽略'; + String get calendarPendingChangesPreventRemoval => + '请等待此日历的待处理更改完成同步,然后再删除或移除它。'; @override - String get notificationDetailsHidden => '根据隐私设置,详细信息已隐藏。'; + String get calendarSubscriptions => '日历订阅'; @override - String get previousMonth => '上个月'; + String get calendarSubscriptionsDescription => '添加从安全 WebCal URL 刷新的只读日历。'; @override - String get nextMonth => '下个月'; + String get addCalendarSubscription => '添加日历订阅'; @override - String get openMonthView => '打开月视图'; + String get subscriptionName => '本地名称'; @override - String get previousYear => '上一年'; + String get subscriptionUrl => '订阅 URL'; @override - String get nextYear => '下一年'; + String get subscriptionUrlHelp => + '输入 HTTPS 或 webcal URL。BusyMax 会将完整 URL 保存在安全存储中。'; @override - String get openYearView => '打开年视图'; + String get subscriptionUrlInvalid => '请输入不含用户信息或片段的有效 HTTPS 或 webcal URL。'; @override - String weekNumberTooltip(int number) { - return '第 $number 周'; - } + String get subscriptionColor => '本地颜色'; @override - String get resizeAllDayPanel => '调整全天面板的大小'; + String get subscriptionColorHelp => '使用六位颜色,例如 #3584E4。'; @override - String scheduleItemCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count 项', - one: '1 项', - ); - return '$_temp0'; - } + String get subscriptionColorInvalid => '请输入六位十六进制颜色。'; @override - String get readOnlyCalendar => '此日历为只读。'; + String get subscriptionRefreshMode => '刷新频率'; @override - String get selectTimeZone => '选择时区'; + String get subscriptionAutomatic => '自动'; @override - String get searchLocations => '搜索地点'; + String get subscriptionHourly => '每小时'; @override - String get noLocationsFound => '未找到地点'; + String get subscriptionSixHours => '每六小时'; @override - String get requiredField => 'This field is required.'; + String get subscriptionDaily => '每天'; @override - String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + String subscriptionSafeOrigin(String origin) { + return '来源:$origin'; + } @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get subscriptionSafeOriginUnavailable => '请输入有效 URL 以预览其安全来源。'; @override - String get nextcloudProvider => 'Nextcloud'; + String get subscriptionReadOnly => '只读订阅'; @override - String get appleICloudTasksProvider => 'Apple iCloud'; + String get subscriptionNeverRefreshed => '尚未刷新'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String subscriptionLastRefresh(String time) { + return '上次成功刷新:$time'; + } @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String subscriptionNextRefresh(String time) { + return '下次刷新:$time'; + } @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get subscriptionStatusHealthy => '已是最新'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String subscriptionStatusIssue(String code) { + return '刷新问题:$code'; + } @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get refreshNow => '立即刷新'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get unsubscribe => '取消订阅'; @override - String get appleAccountEmail => 'Apple Account email'; + String unsubscribeCalendarTitle(String name) { + return '要取消订阅“$name”吗?'; + } @override - String get appleAppSpecificPassword => 'App-specific password'; + String get unsubscribeCalendarConfirmation => '这会移除本地订阅及其缓存的日程。已发布的日历不会更改。'; @override - String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + String get addSubscriptionAction => '添加订阅'; @override - String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + String subscriptionOperationFailed(String error) { + return '日历订阅失败:$error'; + } @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get subscriptions => '订阅'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get calendarImport => '日历导入'; @override - String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + String get calendarImportDescription => '选择文件,查看其中的日程,然后选择接收这些日程的可写日历。'; @override - String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + String get importIcsFile => '导入 .ics 文件'; @override - String get connectAccountAction => 'Connect'; + String get importIcsPreview => '导入日历日程'; @override - String get cancelAccountConnection => 'Cancel connection'; + String importEventsFound(int count) { + return '可导入日程集:$count'; + } @override - String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + String importInvalidEvents(int count) { + return '无效日程:$count'; + } @override - String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + String importFieldsOmitted(String fields) { + return '有意省略:$fields'; + } @override - String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + String get noWritableCalendars => '没有可用的可写目标日历。'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get importDestinationCalendar => '目标日历'; @override - String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + String get importIcsConfirm => '导入日程'; @override - String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + String get importIcsComplete => '导入完成'; @override - String get collectionSettings => 'Calendars and task lists'; + String importQueued(int count) { + return '已导入或已排队:$count'; + } @override - String get calendarContent => 'Calendar events'; + String importDuplicatesSkipped(int count) { + return '已跳过重复项:$count'; + } @override - String get taskContent => 'Tasks'; + String importUnsupportedSets(int count) { + return '不支持的重复集:$count'; + } @override - String get readOnlySharedCollection => 'Read-only'; + String importIcsFailed(String error) { + return '无法导入日历文件:$error'; + } @override - String get pendingLocally => 'Pending locally'; + String get networkOffline => '离线'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get networkOfflineDescription => '恢复连接后将同步更改。'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get networkOfflineTryAgain => '您当前处于离线状态。请连接互联网后重试。'; @override - String get operationFailed => 'Operation failed'; + String repeatOnMonthDaysSummaryMultiple(String days) { + return '$days'; + } @override - String get keepServerVersion => 'Keep server version'; + String get repeatSummarySeparator => ''; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String repeatMonthDayValue(String day) { + return '$day日'; + } @override - String get duplicateLocalItem => 'Duplicate as new item'; + String repeatWeekdayListPair(String first, String second) { + return '$first和$second'; + } @override - String get davConnectionState => 'Connection state'; + String repeatWeekdayListStart(String first, String rest) { + return '$first、$rest'; + } @override - String get davConnected => 'Connected'; + String repeatMonthDayListPair(String first, String second) { + return '$first、$second'; + } @override - String get davConnecting => 'Connecting…'; + String repeatMonthDayListStart(String first, String rest) { + return '$first、$rest'; + } @override - String get davSignedOut => 'Signed out'; + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } @override - String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first和$second'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first、$rest'; + } @override - String get refreshCollections => 'Refresh calendars and task lists'; + String repeatYearlyMonthListPair(String first, String second) { + return '$first和$second'; + } @override - String nextcloudServerHost(String host) { - return 'Server: $host'; + String repeatYearlyMonthListStart(String first, String rest) { + return '$first、$rest'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency$month$day'; + } @override - String get collectionSupportsTasks => 'Task list'; + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency$month的$days'; + } @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency$months的$day'; + } @override - String get writableCollection => 'Writable'; + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency$months的$days'; + } @override - String get sharedCollection => 'Shared'; + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一个$days', + 'second': '第二个$days', + 'third': '第三个$days', + 'fourth': '第四个$days', + 'fifth': '第五个$days', + 'secondToLast': '倒数第二个$days', + 'last': '最后一个$days', + 'other': '$days', + }); + return '$frequency$month的$_temp0'; + } @override - String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一个$days', + 'second': '第二个$days', + 'third': '第三个$days', + 'fourth': '第四个$days', + 'fifth': '第五个$days', + 'secondToLast': '倒数第二个$days', + 'last': '最后一个$days', + 'other': '$days', + }); + return '$frequency$months的$_temp0'; } +} + +/// The translations for Chinese, using the Han script (`zh_Hant`). +class AppLocalizationsZhHant extends AppLocalizationsZh { + AppLocalizationsZhHant() : super('zh_Hant'); @override - String collectionSyncError(String code) { - return 'Sync issue: $code'; - } + String get windowsSupport => '支援'; @override - String get syncConflicts => 'Synchronization conflicts'; + String get windowsThirdPartyLicenses => '第三方授權'; @override - String remoteChangedAt(String time) { - return 'Server changed: $time'; - } + String get windowsSearch => '搜尋'; @override - String localPendingEdit(String summary) { - return 'Local edit: $summary'; - } + String get windowsStartupDisabledByUser => '使用者已在 Windows 設定中停用。'; @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get windowsStartupDisabledByPolicy => '已由 Windows 原則停用。'; @override - String get recurringEventScope => 'Recurring event scope'; + String get windowsStartupUnavailable => '從 MSIX 套件安裝 BusyMax 後即可使用。'; @override - String get entireSeries => 'Entire series'; + String get windowsReminderExitNotice => + '完全結束 BusyMax 後,提醒會停止。請讓應用程式在背景執行以接收提醒。'; @override - String get singleOccurrence => 'This occurrence'; + String get windowsProductVersionLabel => '產品版本'; @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get windowsPackageVersionLabel => 'Windows 套件版本'; @override - String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + String get windowsUnpackaged => '未封裝'; @override - String get taskDueBeforeStart => '截止时间不能早于开始时间。'; + String get windowsAgendaLoadMore => '載入更多議程項目'; @override - String get taskStartDueTimeModeMismatch => '请同时设置开始和截止时间,或将任务设为全天。'; + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '星期一', + 'TU': '星期二', + 'WE': '星期三', + 'TH': '星期四', + 'FR': '星期五', + 'SA': '星期六', + 'SU': '星期日', + 'other': '$day', + }); + return '$_temp0'; + } @override - String deleteCalendarConfirmation(String title) { - return '删除“$title”?'; + String repeatOnTwoMonthDaysSummary(String first, String second) { + return '$first、$second'; } @override - String get networkOffline => '离线'; + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency$month的$firstDay和$secondDay'; + } @override - String get networkOfflineDescription => '恢复连接后将同步更改。'; + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency$firstMonth和$secondMonth的$day'; + } @override - String get networkOfflineTryAgain => '您当前处于离线状态。请连接互联网后重试。'; -} + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency$firstMonth和$secondMonth的$firstDay和$secondDay'; + } -/// The translations for Chinese, using the Han script (`zh_Hant`). -class AppLocalizationsZhHant extends AppLocalizationsZh { - AppLocalizationsZhHant() : super('zh_Hant'); + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency$firstMonth和$secondMonth的$days'; + } @override String get appTitle => 'BusyMax'; @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + '連接 Google、Microsoft、Apple iCloud Calendar 或 Nextcloud 帳戶。'; @override String get googlePermissionsConsentNotice => '在 Google 權限畫面中,同時選取行事曆和待辦事項權限。'; @@ -3822,7 +5277,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + '新增所有要使用的帳戶。BusyMax 會同步每個帳戶中支援的行事曆、活動、待辦清單和待辦事項。'; @override String get onboardingPreferencesStepTitle => '選擇系統設定'; @@ -3898,6 +5353,42 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get calendars => '行事曆'; + @override + String get newCalendar => '新增行事曆'; + + @override + String get calendarColor => '行事曆顏色'; + + @override + String calendarColorOption(int number) { + return '顏色 $number'; + } + + @override + String get calendarManagementUnsupported => '此提供者不支援 BusyMax 中的行事曆管理。'; + + @override + String get primaryCalendarCannotDelete => '無法刪除主要行事曆。'; + + @override + String calendarCreateFailed(String error) { + return '無法建立行事曆:$error'; + } + + @override + String get calendarCreatedRefreshPending => + '行事曆已建立,但 BusyMax 無法重新整理帳戶。它會在下次同步後顯示。'; + + @override + String calendarUpdateFailed(String error) { + return '無法更新行事曆:$error'; + } + + @override + String calendarDeleteFailed(String error) { + return '無法刪除行事曆:$error'; + } + @override String get newEvent => '新增活動'; @@ -3921,7 +5412,11 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String moreItems(int count) { - return '還有 $count 項'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '還有 $countString 項'; } @override @@ -3934,28 +5429,123 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get scheduleUnavailable => '無法使用行程'; @override - String get scheduleNoSources => '沒有可見的行事曆或待辦清單'; + String get scheduleNoSources => '沒有可見的行事曆或待辦清單'; + + @override + String get scheduleNoSourcesDescription => '請在設定中選擇要顯示的內容,然後重新整理。'; + + @override + String get scheduleSignInRequired => '連結帳戶'; + + @override + String get scheduleSignInDescription => '登入以同步行事曆和待辦事項。'; + + @override + String get scheduleNoSearchResults => '沒有相符的活動或待辦事項'; + + @override + String get scheduleNoSearchResultsDescription => '請嘗試其他搜尋內容或清除目前的篩選條件。'; + + @override + String get refresh => '重新整理'; + + @override + String get trayOpenBusyMax => '打開 BusyMax'; + + @override + String get trayShowBusyMax => '顯示 BusyMax'; + + @override + String get trayNewEvent => '新增活動…'; + + @override + String get trayNewTask => '新增待辦事項…'; + + @override + String get trayToday => '今天'; + + @override + String get trayAllDay => '全天'; + + @override + String get trayNow => '現在'; + + @override + String get trayCalendarEvent => '行事曆活動'; + + @override + String get trayUntitledEvent => '未命名活動'; + + @override + String get trayNothingElseToday => '今天沒有其他內容'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '今天有 $count 個任務到期', + one: '今天有 1 個任務到期', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => '打開今天的日程'; + + @override + String get traySyncNow => '立即同步'; + + @override + String get traySyncing => '正在同步…'; + + @override + String get trayNotConnected => '未連接'; @override - String get scheduleNoSourcesDescription => '請在設定中選擇要顯示的內容,然後重新整理。'; + String get trayNotYetSynced => '尚未同步'; @override - String get scheduleSignInRequired => '連結帳戶'; + String get trayLastSyncedJustNow => '剛剛同步'; @override - String get scheduleSignInDescription => '登入以同步行事曆和待辦事項。'; + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 分鐘前同步', + one: '1 分鐘前同步', + ); + return '$_temp0'; + } @override - String get scheduleNoSearchResults => '沒有相符的活動或待辦事項'; + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 小時前同步', + one: '1 小時前同步', + ); + return '$_temp0'; + } @override - String get scheduleNoSearchResultsDescription => '請嘗試其他搜尋內容或清除目前的篩選條件。'; + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 天前同步', + one: '1 天前同步', + ); + return '$_temp0'; + } @override - String get refresh => '重新整理'; + String get traySettings => '設定'; @override - String get trayOpenBusyMax => '開啟 BusyMax'; + String get trayQuitBusyMax => '結束 BusyMax'; @override String get agendaLoadMoreOverdue => '載入更多逾期待辦事項'; @@ -4037,130 +5627,123 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get guests => '參與者'; @override - String get noGuests => '沒有參與者'; + String get noGuests => '無參與者'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => '必要'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => '選用'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => '會議'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => '添加 Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => '添加 Microsoft Teams 會議'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => '已添加在線會議'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => '要求回覆'; @override - String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + String get requestResponsesDescription => '要求參與者回復邀請。'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => '隱藏參與者清單'; @override - String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + String get hideGuestListDescription => '參與者無法查看其他受邀者。'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => '允許提出新時間'; @override - String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + String get allowNewTimeProposalsDescription => '參與者可以建議其他會議時間。'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => '通知參與者?'; @override - String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + String get notifyGuestsSaveMessage => '此會議有參與者。保存時發送邀請或活動更新嗎?'; @override - String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + String get notifyGuestsDeleteMessage => '此會議有參與者。刪除時發送取消通知嗎?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => '傳送更新'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => '傳送取消通知'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => '不要傳送'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => '保存會議?'; @override - String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + String get microsoftNotifyGuestsSaveMessage => 'Microsoft 將向參與者發送邀請或活動更新。'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => '刪除會議?'; @override - String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + String get microsoftNotifyGuestsDeleteMessage => 'Microsoft 將向參與者發送取消通知。'; @override - String get organizer => 'Organizer'; + String get organizer => '主辦人'; @override - String get yourResponse => 'Your response'; + String get yourResponse => '你的回覆'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => '參與者回覆'; @override - String get respond => 'Respond'; + String get respond => '回復'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => '接受'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => '暫定'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => '拒絕'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => '加入會議'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => '已接受'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => '暫定'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => '已拒絕'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => '等待回復'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => '未回復'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => '組織者'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return '無法發送回復:$error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => '無法打開會議鏈接。'; @override String get description => '說明'; @@ -4206,10 +5789,14 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes 分鐘前', + other: '$minutesString 分鐘前', one: '1 分鐘前', ); return '$_temp0'; @@ -4220,10 +5807,14 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours 小時前', + other: '$hoursString 小時前', one: '1 小時前', ); return '$_temp0'; @@ -4231,10 +5822,15 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days 天前', + other: '$daysString 天前', one: '1 天前', ); return '$_temp0'; @@ -4415,7 +6011,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get feedbackTechnicalDetailsDisclosure => - '只會加入您的 Linux 作業系統版本和應用程式語系。不會包含記錄、帳戶資料、檔案名稱或其他診斷資訊。'; + '只會加入您的作業系統名稱與版本和應用程式語系。不會包含記錄、帳戶資料、檔案名稱或其他診斷資訊。'; @override String get feedbackCategoryRequired => '請選擇類別。'; @@ -4493,12 +6089,12 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String removeAccountTitle(String account) { - return '要從 BusyMax 移除 $account 嗎?'; + return '從 BusyMax 中移除 $account?'; } @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + '這會從此設備刪除緩存的任務、日曆、活動、提醒和待處理的離線更改。未同步的更改將會丟失。提供商中的日曆、活動、任務列表和任務副本不會被刪除。'; @override String get revokeGoogleAccess => '同時撤銷 BusyMax 對此 Google 帳戶的存取權'; @@ -4517,21 +6113,21 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { '該帳戶已從此裝置移除,但無法撤銷 BusyMax 對您的 Google 帳戶的存取權。您可以在 Google 帳戶中手動撤銷該權限。'; @override - String get newTaskList => '新增待辦清單'; + String get newTaskList => '新任務列表'; @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return '無法創建任務列表:$error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return '無法重命名任務列表:$error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return '無法刪除任務列表:$error'; } @override @@ -4556,15 +6152,13 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get deleteList => '刪除清單'; @override - String get unshare => '撤回分享'; + String get unshare => '取消共享'; @override - String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + String get readOnlyTaskListCannotRename => '此任務列表為只讀,無法重命名。'; @override - String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + String get taskListCannotDelete => '使用當前權限無法刪除此任務列表。'; @override String get builtInMicrosoftList => '內建'; @@ -4575,17 +6169,17 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String deleteListConfirmation(String title) { - return '要從 Google Tasks 刪除「$title」嗎?'; + return '要從 Google Tasks 中刪除“$title”嗎?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return '要刪除“$title”及其所有任務嗎?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return '要取消此賬戶對“$title”的共享嗎?'; } @override @@ -4710,10 +6304,10 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get doneStatus => '已完成'; @override - String get taskStatus => 'Status'; + String get taskStatus => '狀態'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => '無狀態'; @override String get taskStatusNeedsAction => '需要動作'; @@ -4722,60 +6316,76 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get taskStatusInProcess => '進行中'; @override - String get taskStatusCompleted => '完成'; + String get taskStatusCompleted => '已完成'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => '已取消'; @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '已完成 $percentString%'; } @override - String get completionDate => 'Completion date'; + String get completionDate => '完成日期'; @override - String get priority => '優先'; + String get priority => '優先順序'; @override - String get priorityNone => 'No priority'; + String get priorityNone => '無優先順序'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '優先級 $priorityString · 高'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '優先級 $priorityString · 中'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '優先級 $priorityString · 低'; } @override - String get taskUrl => 'URL'; + String get taskUrl => '任務 URL'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => '請輸入包含方案的絕對 URL。'; @override - String get classification => 'Classification'; + String get classification => '分類'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => '共享時顯示完整任務'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => '共享時僅顯示忙碌狀態'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => '共享時隱藏此任務'; @override - String get pinTask => 'Pin task'; + String get pinTask => '固定任務'; @override String get notes => '備註'; @@ -4811,78 +6421,81 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get reminder => '提醒'; @override - String get addReminder => '新增提醒'; + String get addReminder => '添加提醒'; @override - String get reminders => 'Reminders'; + String get reminders => '提醒'; @override String get noReminders => '無提醒'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => '編輯提醒'; @override - String get beforeTaskStarts => '在任務開始前'; + String get beforeTaskStarts => '待辦事項開始前'; @override - String get beforeTaskDue => '任務截止前'; + String get beforeTaskDue => '待辦事項到期前'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => '任務開始後'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => '任務到期後'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => '相對於任務開始日期'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => '相對於任務到期日期'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => '時間'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => '在指定日期和時間'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => '數量'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => '單位'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => '秒'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => '分鐘'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => '小時'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => '天'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => '週'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => '任務開始時'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => '任務到期時'; @override - String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + String get unsupportedReminder => '此提醒類型會保留,但無法編輯其時間。'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => '保留相關提醒?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '此日期有 $countString 個相關提醒。要保留它們當前的日期和時間嗎?'; } @override @@ -4925,101 +6538,145 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get repeatYearly => '每年'; @override - String get repeatEvery => '重複循環'; + String get repeatEvery => '重複間隔'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => '重複日期'; @override - String get repeatEnd => '停止重複'; + String get repeatEnd => '結束重複'; @override - String get repeatNever => 'Never'; + String get repeatNever => '永不'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => '指定日期'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => '指定次數後'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => '重複次數'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => '每月日期'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => '月份'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => '星期位置'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => '特定日期'; @override - String get repeatFirst => 'First'; + String get repeatFirst => '第一'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => '第二'; @override - String get repeatThird => 'Third'; + String get repeatThird => '第三'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => '第四'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => '第五'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => '倒數第二'; @override - String get repeatLast => 'Last'; + String get repeatLast => '最後'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => '日期'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => '工作日'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => '週末'; + + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '星期一', + 'TU': '星期二', + 'WE': '星期三', + 'TH': '星期四', + 'FR': '星期五', + 'SA': '星期六', + 'SU': '星期日', + 'day': '日期', + 'weekday': '工作日', + 'weekend': '週末', + 'other': '$day', + }); + return '$_temp0'; + } @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 天'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 週'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 個月'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 年'; } @override String repeatOnDaysSummary(String days) { - return '於 $days'; + return '在 $days'; } @override String repeatOnMonthDaysSummary(String days) { - return '於 $days 天'; + return '$days'; } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一個$days', + 'second': '第二個$days', + 'third': '第三個$days', + 'fourth': '第四個$days', + 'fifth': '第五個$days', + 'secondToLast': '倒數第二個$days', + 'last': '最後一個$days', + 'other': '$days', + }); + return '$_temp0'; } @override @@ -5029,17 +6686,30 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String repeatTimesSummary(int count) { - return '$count 次'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '重複 $countString 次', + ); + return '$_temp0'; } @override String repeatUntilSummary(String date) { - return '到 $date'; + return '直到 $date'; } @override - String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + String get unsupportedRecurrencePreserved => '此重複規則使用了編輯器不會更改的選項。'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return '此重複規則無法用於 $provider。'; + } @override String get importance => '重要性'; @@ -5094,14 +6764,14 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get subtasks => '子待辦事項'; @override - String get duplicateTask => '再製任務'; + String get duplicateTask => '複製待辦事項'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => '任務已複製。'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return '無法複製任務:$error'; } @override @@ -5164,7 +6834,11 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get sync => '同步'; @override - String get manualFullSync => '手動完整同步'; + String get forceFullResync => '強制完整重新同步'; + + @override + String get forceFullResyncDescription => + '從每個已連結的帳號完整重新載入所有資料。僅在疑難排解同步問題時使用此功能。'; @override String get runInBackgroundWhenClosed => '視窗關閉後繼續在背景執行'; @@ -5207,6 +6881,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get eventReminders => '活動提醒'; + @override + String get onState => '開啟'; + @override String get taskReminders => '待辦事項提醒'; @@ -5234,6 +6911,17 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get notifications => '通知'; + @override + String get windowsNotificationsUnavailable => 'Windows 通知無法使用'; + + @override + String get windowsNotificationsUnpackaged => + '此未封裝的開發版本無法使用 Windows 通知。請安裝測試簽署的 MSIX 以測試提醒。'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax 無法初始化 Windows 通知。在解決此安裝問題之前,提醒不會顯示。'; + @override String get appearance => '外觀'; @@ -5243,6 +6931,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get themeSystem => '系統'; + @override + String get settingsSystem => '系統'; + @override String get themeLight => '淺色'; @@ -5317,7 +7008,11 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String pendingOpAttempts(int count) { - return '嘗試次數=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '嘗試次數=$countString'; } @override @@ -5361,7 +7056,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String conflictNotificationBody(String summary) { - return '一項待處理的本機變更遭到封鎖。$summary'; + return '一項擱置中的本機變更已被封鎖。$summary'; } @override @@ -5369,10 +7064,14 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '今天有 $count 項待辦事項到期。', + other: '今天有 $countString 項待辦事項到期。', one: '今天有 1 項待辦事項到期。', ); return '$_temp0'; @@ -5397,7 +7096,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get notificationSnoozeAction => '10 分鐘後提醒'; @override - String get notificationDismissAction => '忽略'; + String get notificationDismissAction => '關閉'; @override String get notificationDetailsHidden => '根據隱私權設定,詳細資料已隱藏。'; @@ -5422,7 +7121,11 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String weekNumberTooltip(int number) { - return '第 $number 週'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return '第 $numberString 週'; } @override @@ -5430,10 +7133,14 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count 項', + other: '$countString 項', one: '1 項', ); return '$_temp0'; @@ -5446,20 +7153,19 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get selectTimeZone => '選擇時區'; @override - String get searchLocations => '搜尋地點'; + String get searchLocations => '搜索地點'; @override - String get noLocationsFound => '找不到地點'; + String get noLocationsFound => '未找到地點'; @override - String get requiredField => 'This field is required.'; + String get requiredField => '此字段為必填項。'; @override - String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + String get providerConnectionDescription => '連接以下任一提供商中的日曆和任務。'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Apple iCloud 行事曆'; @override String get nextcloudProvider => 'Nextcloud'; @@ -5468,210 +7174,409 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Nextcloud 待辦事項'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => '新增 Apple iCloud 行事曆帳戶'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => '新增 Nextcloud 帳戶'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => '正在連接 Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => '正在等待 Nextcloud 授權…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => '連接 Apple iCloud 行事曆'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Apple 賬戶電子郵件'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'App 專用密碼'; @override - String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + String get appleAppSpecificPasswordHelp => '為 Apple 賬戶啓用雙重認證後,創建 App 專用密碼。'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + '重置 Apple 賬戶密碼會撤銷 App 專用密碼。'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => '連接 Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Nextcloud 伺服器或 CalDAV 位址'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + '輸入 Nextcloud 服務器 URL,或粘貼從 Nextcloud 複製的主要 CalDAV 地址。'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax 將打開瀏覽器。請在那裡批准訪問,然後返回 BusyMax。'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => '連接'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => '取消連接'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + '賬戶已在本地移除,但無法撤銷 Nextcloud App 密碼。'; @override - String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + String get davCachedOfflineNotice => '日曆和任務數據會緩存在本地,以供離線使用。'; @override - String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + String get davReauthenticationRequired => '重新連接此賬戶以恢復同步。'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get davTemporarilyUnavailable => '此賬戶暫時不可用。'; @override - String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + String get davPermissionChanged => '服務器權限已更改。待處理的編輯已暫停。'; @override - String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + String get davUnsupportedServer => '不支持此服務器或提供商配置。'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => '行事曆和待辦清單'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => '行事曆活動'; @override - String get taskContent => 'Tasks'; + String get taskContent => '待辦事項'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => '唯讀'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => '本機擱置中'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => '因衝突而封鎖'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => '重新連接前阻止'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => '操作失敗'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => '保留伺服器版本'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => '查看並重新應用本地更改'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => '複製為新項目'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => '連線狀態'; @override - String get davConnected => 'Connected'; + String get davConnected => '已連線'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => '正在連線…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => '已登出'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return '上次成功同步:$time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => '尚未同步'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => '刷新日曆和任務列表'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return '服務器:$host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => '活動日曆'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => '任務列表'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => '活動和任務'; @override - String get writableCollection => 'Writable'; + String get writableCollection => '可寫'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => '已共享'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return '上次同步:$time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return '同步問題:$code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => '同步衝突'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return '服務器更改:$time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return '本地編輯:$summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => '無法解決衝突。'; + + @override + String get recurringEventScope => '重複活動範圍'; + + @override + String get entireSeries => '整個系列'; + + @override + String get singleOccurrence => '此事件'; + + @override + String get thisAndFollowingEvents => '此事件及後續事件'; + + @override + String get thisAndFutureUnavailable => '此提供者不支援。'; + + @override + String get thisAndFutureMoveUnavailable => '無法安全移動此活動及後續活動。請選擇此活動或整個系列。'; @override - String get recurringEventScope => 'Recurring event scope'; + String get entireSeriesMoveUnavailable => '本機沒有可用的重複規則。請只移動此活動。'; @override - String get entireSeries => 'Entire series'; + String get copyEventAndDeleteOriginal => '複製活動並刪除原活動?'; @override - String get singleOccurrence => 'This occurrence'; + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax 無法將此活動直接從 $source 移動到 $destination。應用程式會先建立副本,並且只在複製成功後刪除原活動。活動 ID 將會變更;參與者的回覆狀態可能會重設,並可能傳送邀請或取消通知;會議連結、附件、提醒、提供者特有的欄位和重複例外可能無法保留。'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => '複製並刪除'; @override - String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + String get chooseRecurringEventScope => '選擇此變更適用於整個系列、僅此活動,還是此活動及後續活動。'; @override String get taskDueBeforeStart => '到期時間不得早於開始時間。'; @override - String get taskStartDueTimeModeMismatch => '請同時設定開始與到期時間,或將工作設為全天。'; + String get taskStartDueTimeModeMismatch => '請同時設定開始與到期時間,或將待辦事項設為全天。'; @override String deleteCalendarConfirmation(String title) { return '要刪除「$title」嗎?'; } + @override + String get setCustomCalendarName => '設定自訂名稱'; + + @override + String get setAction => '設定'; + + @override + String get removeFromMyCalendars => '從「我的日曆」移除'; + + @override + String get removeAction => '移除'; + + @override + String removeCalendarConfirmation(String title) { + return '要從您的 Google 日曆清單移除「$title」嗎?共用日曆及其活動不會被刪除。'; + } + + @override + String get calendarCannotRemove => '無法從此賬戶刪除或移除此日曆。'; + + @override + String get calendarPendingChangesPreventRemoval => + '請等待此日曆的待處理更改完成同步,然後再刪除或移除它。'; + + @override + String get calendarSubscriptions => '行事曆訂閱'; + + @override + String get calendarSubscriptionsDescription => '添加從安全 WebCal URL 刷新的只讀日曆。'; + + @override + String get addCalendarSubscription => '新增行事曆訂閱'; + + @override + String get subscriptionName => '本機名稱'; + + @override + String get subscriptionUrl => '訂閱 URL'; + + @override + String get subscriptionUrlHelp => + '輸入 HTTPS 或 webcal URL。BusyMax 會將完整 URL 保存在安全存儲中。'; + + @override + String get subscriptionUrlInvalid => '請輸入不含用戶信息或片段的有效 HTTPS 或 webcal URL。'; + + @override + String get subscriptionColor => '本地顏色'; + + @override + String get subscriptionColorHelp => '使用六位顏色,例如 #3584E4。'; + + @override + String get subscriptionColorInvalid => '請輸入六位十六進制顏色。'; + + @override + String get subscriptionRefreshMode => '刷新頻率'; + + @override + String get subscriptionAutomatic => '自動'; + + @override + String get subscriptionHourly => '每小時'; + + @override + String get subscriptionSixHours => '每六小時'; + + @override + String get subscriptionDaily => '每天'; + + @override + String subscriptionSafeOrigin(String origin) { + return '來源:$origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => '請輸入有效 URL 以預覽其安全來源。'; + + @override + String get subscriptionReadOnly => '只讀訂閱'; + + @override + String get subscriptionNeverRefreshed => '尚未刷新'; + + @override + String subscriptionLastRefresh(String time) { + return '上次成功刷新:$time'; + } + + @override + String subscriptionNextRefresh(String time) { + return '下次刷新:$time'; + } + + @override + String get subscriptionStatusHealthy => '已是最新'; + + @override + String subscriptionStatusIssue(String code) { + return '刷新問題:$code'; + } + + @override + String get refreshNow => '立即重新整理'; + + @override + String get unsubscribe => '取消訂閱'; + + @override + String unsubscribeCalendarTitle(String name) { + return '要取消訂閱“$name”嗎?'; + } + + @override + String get unsubscribeCalendarConfirmation => '這會移除本地訂閱及其緩存的活動。已發佈的日曆不會更改。'; + + @override + String get addSubscriptionAction => '添加訂閱'; + + @override + String subscriptionOperationFailed(String error) { + return '日曆訂閱失敗:$error'; + } + + @override + String get subscriptions => '訂閱'; + + @override + String get calendarImport => '匯入行事曆'; + + @override + String get calendarImportDescription => '選擇文件,查看其中的活動,然後選擇接收這些活動的可寫日曆。'; + + @override + String get importIcsFile => '匯入 .ics 檔案'; + + @override + String get importIcsPreview => '匯入行事曆活動'; + + @override + String importEventsFound(int count) { + return '可導入活動集:$count'; + } + + @override + String importInvalidEvents(int count) { + return '無效活動:$count'; + } + + @override + String importFieldsOmitted(String fields) { + return '有意省略:$fields'; + } + + @override + String get noWritableCalendars => '沒有可用的可寫目標日曆。'; + + @override + String get importDestinationCalendar => '目標日曆'; + + @override + String get importIcsConfirm => '導入活動'; + + @override + String get importIcsComplete => '匯入完成'; + + @override + String importQueued(int count) { + return '已導入或已排隊:$count'; + } + + @override + String importDuplicatesSkipped(int count) { + return '已跳過重複項:$count'; + } + + @override + String importUnsupportedSets(int count) { + return '不支持的重複集:$count'; + } + + @override + String importIcsFailed(String error) { + return '無法導入日曆文件:$error'; + } + @override String get networkOffline => '離線'; @@ -5680,4 +7585,139 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get networkOfflineTryAgain => '您目前處於離線狀態。請連接網際網路後再試一次。'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return '$days'; + } + + @override + String get repeatSummarySeparator => ''; + + @override + String repeatMonthDayValue(String day) { + return '$day日'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first和$second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first、$second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first和$second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first和$second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency$month$day'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency$month的$days'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency$months的$day'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency$months的$days'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一個$days', + 'second': '第二個$days', + 'third': '第三個$days', + 'fourth': '第四個$days', + 'fifth': '第五個$days', + 'secondToLast': '倒數第二個$days', + 'last': '最後一個$days', + 'other': '$days', + }); + return '$frequency$month的$_temp0'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一個$days', + 'second': '第二個$days', + 'third': '第三個$days', + 'fourth': '第四個$days', + 'fifth': '第五個$days', + 'secondToLast': '倒數第二個$days', + 'last': '最後一個$days', + 'other': '$days', + }); + return '$frequency$months的$_temp0'; + } } diff --git a/lib/main.dart b/lib/main.dart index 21456796..163a824f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,106 +1,3 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:system_theme/system_theme.dart'; +import 'main_linux.dart' as linux; -import 'src/app/app_bootstrap.dart'; -import 'src/app/app_theme.dart'; -import 'src/app/busymax_app.dart'; -import 'src/config/build_config.dart'; -import 'src/core/logging/redacting_logger.dart'; -import 'src/demo/demo_profile.dart'; -import 'src/platform/gtk_font_service.dart'; -import 'src/platform/linux_header_bar_service.dart'; - -Future main(List arguments) async { - final binding = WidgetsFlutterBinding.ensureInitialized(); - binding.deferFirstFrame(); - final buildConfig = BuildConfig.fromEnvironment(); - final LocalSettingsStore settingsStore; - if (buildConfig.useFakeProviderData) { - final settings = busyMaxDemoSettings(buildConfig.demoTheme); - settingsStore = InMemoryLocalSettingsStore(settings.toJson()); - } else { - settingsStore = const JsonFileLocalSettingsStore(); - } - - final systemAccentFuture = SystemTheme.accentColor.load(); - final initialGtkFontFuture = const GtkFontService().getGtkFont(); - final initialAppSettingsFuture = loadInitialAppSettings(settingsStore); - final initialAppSettings = await initialAppSettingsFuture; - final gtkThemeService = const GtkThemeService(); - await gtkThemeService.setPreferDark( - switch (initialAppSettings.themeModePreference) { - BusyMaxThemeModePreference.system => null, - BusyMaxThemeModePreference.light => false, - BusyMaxThemeModePreference.dark => true, - }, - ); - final desktopSettings = await Future.wait([ - systemAccentFuture, - initialGtkFontFuture, - gtkThemeService.getGtkThemeColors(), - ]); - configureLogging(); - - final initialGtkFont = desktopSettings[1] as GtkFontSettings?; - final initialGtkThemeColors = desktopSettings[2] as GtkThemeColors?; - await _applyInitialNativeHeaderBarTheme( - settings: initialAppSettings, - gtkFont: initialGtkFont, - gtkThemeColors: initialGtkThemeColors, - ); - - final overrides = [ - buildConfigProvider.overrideWithValue(buildConfig), - localSettingsStoreProvider.overrideWithValue(settingsStore), - initialAppSettingsProvider.overrideWithValue(initialAppSettings), - initialGtkFontSettingsProvider.overrideWithValue(initialGtkFont), - initialGtkThemeColorsProvider.overrideWithValue(initialGtkThemeColors), - ]; - final demoProfile = buildConfig.useFakeProviderData - ? await BusyMaxDemoProfile.create() - : null; - final applicationOverrides = [...overrides, ...?demoProfile?.overrides]; - - runApp( - ProviderScope( - overrides: applicationOverrides, - child: BusyMaxApp( - startMinimizedAtLaunch: arguments.contains('--start-minimized'), - ), - ), - ); - binding.allowFirstFrame(); -} - -Future _applyInitialNativeHeaderBarTheme({ - required AppSettings settings, - required GtkFontSettings? gtkFont, - required GtkThemeColors? gtkThemeColors, -}) async { - final platformDispatcher = WidgetsBinding.instance.platformDispatcher; - final brightness = switch (settings.themeModePreference) { - BusyMaxThemeModePreference.system => platformDispatcher.platformBrightness, - BusyMaxThemeModePreference.light => Brightness.light, - BusyMaxThemeModePreference.dark => Brightness.dark, - }; - final highContrast = platformDispatcher.accessibilityFeatures.highContrast; - final theme = buildBusyMaxTheme( - brightness: brightness, - accentColor: gtkThemeColors?.accent ?? SystemTheme.accentColor.accent, - family: settings.themeFamily, - gtkFontFamily: gtkFont?.family, - gtkFontSize: gtkFont?.size, - gtkThemeColors: gtkThemeColors, - highContrast: highContrast, - ); - final headerBarService = LinuxHeaderBarService(); - try { - await headerBarService.initialize(); - await headerBarService.setTheme( - busyMaxHeaderBarThemeFor(theme, highContrast: highContrast), - ); - } finally { - headerBarService.dispose(); - } -} +Future main(List arguments) => linux.main(arguments); diff --git a/lib/main_linux.dart b/lib/main_linux.dart new file mode 100644 index 00000000..555a5a5c --- /dev/null +++ b/lib/main_linux.dart @@ -0,0 +1,165 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:system_theme/system_theme.dart'; + +import 'src/app/app_bootstrap.dart'; +import 'src/app/app_theme.dart'; +import 'src/app/busymax_app.dart'; +import 'src/config/build_config.dart'; +import 'src/core/logging/redacting_logger.dart'; +import 'src/core/secrets/portal_encrypted_secret_store.dart'; +import 'src/core/secrets/secret_store.dart'; +import 'src/core/time/linux_gweather_location_source.dart'; +import 'src/core/time/local_time_zone.dart'; +import 'src/core/time/time_zone_catalog.dart'; +import 'src/demo/demo_profile.dart'; +import 'src/features/notifications/desktop_notification_backend.dart'; +import 'src/platform/common/desktop_services.dart'; +import 'src/platform/external_calendar_open_service.dart'; +import 'src/platform/gtk_font_service.dart'; +import 'src/platform/linux/linux_notification_backend.dart'; +import 'src/platform/linux/linux_network_connectivity_monitor.dart'; +import 'src/platform/linux/linux_secret_storage_presentation.dart'; +import 'src/platform/linux_autostart_service.dart'; +import 'src/platform/linux_header_bar_service.dart'; +import 'src/platform/linux_window_service.dart'; + +Future main(List arguments) async { + final binding = WidgetsFlutterBinding.ensureInitialized(); + binding.deferFirstFrame(); + final buildConfig = BuildConfig.fromEnvironment(); + final LocalSettingsStore settingsStore; + if (buildConfig.useFakeProviderData) { + final settings = busyMaxDemoSettings(buildConfig.demoTheme); + settingsStore = InMemoryLocalSettingsStore(settings.toJson()); + } else { + settingsStore = const JsonFileLocalSettingsStore(); + } + + BusyMaxTimeZoneCatalog.configureSystemLocationLoader( + loadLinuxSystemTimeZoneLocations, + ); + const timeZoneSource = LinuxLocalTimeZoneSource(); + final localTimeZone = await timeZoneSource.currentIanaTimeZone(); + final activationService = ExternalCalendarOpenService(); + await activationService.initialize(); + + final systemAccentFuture = SystemTheme.accentColor.load(); + final initialGtkFontFuture = const GtkFontService().getGtkFont(); + final initialAppSettingsFuture = loadInitialAppSettings(settingsStore); + final initialAppSettings = await initialAppSettingsFuture; + final gtkThemeService = const GtkThemeService(); + await gtkThemeService.setPreferDark( + switch (initialAppSettings.themeModePreference) { + BusyMaxThemeModePreference.system => null, + BusyMaxThemeModePreference.light => false, + BusyMaxThemeModePreference.dark => true, + }, + ); + final desktopSettings = await Future.wait([ + systemAccentFuture, + initialGtkFontFuture, + gtkThemeService.getGtkThemeColors(), + ]); + configureLogging(); + + final initialGtkFont = desktopSettings[1] as GtkFontSettings?; + final initialGtkThemeColors = desktopSettings[2] as GtkThemeColors?; + await _applyInitialNativeHeaderBarTheme( + settings: initialAppSettings, + gtkFont: initialGtkFont, + gtkThemeColors: initialGtkThemeColors, + ); + + final overrides = [ + buildConfigProvider.overrideWithValue(buildConfig), + localSettingsStoreProvider.overrideWithValue(settingsStore), + initialAppSettingsProvider.overrideWithValue(initialAppSettings), + initialGtkFontSettingsProvider.overrideWithValue(initialGtkFont), + initialGtkThemeColorsProvider.overrideWithValue(initialGtkThemeColors), + networkConnectivityMonitorProvider.overrideWith((ref) { + final monitor = createLinuxNetworkConnectivityMonitor(); + ref.onDispose(() => unawaited(monitor.dispose())); + return monitor; + }), + desktopWindowServiceProvider.overrideWithValue(const LinuxWindowService()), + desktopAutostartServiceProvider.overrideWith( + (ref) => LinuxAutostartService(), + ), + desktopActivationServiceProvider.overrideWith((ref) { + ref.onDispose(() => unawaited(activationService.dispose())); + return activationService; + }), + desktopNotificationBackendProvider.overrideWith((ref) { + final backend = FreedesktopNotificationBackend(); + ref.onDispose(() => unawaited(backend.close())); + return backend; + }), + desktopNotificationReadinessProvider.overrideWith( + (ref) => const DesktopNotificationReadiness( + DesktopNotificationReadinessState.available, + ), + ), + localTimeZoneSourceProvider.overrideWithValue(timeZoneSource), + localTimeZoneProvider.overrideWithValue(localTimeZone), + secretStoreProvider.overrideWith( + (ref) => Platform.environment['SNAP']?.isNotEmpty ?? false + ? PortalEncryptedSecretStore() + : SecureSecretStore( + ref.watch(secureStorageProvider), + presentation: linuxSecretStoragePresentation, + ), + ), + ]; + final demoProfile = buildConfig.useFakeProviderData + ? await BusyMaxDemoProfile.create() + : null; + final applicationOverrides = [...overrides, ...?demoProfile?.overrides]; + + runApp( + ProviderScope( + overrides: applicationOverrides, + child: LinuxBusyMaxApp( + startMinimizedAtLaunch: arguments.contains( + busyMaxStartMinimizedArgument, + ), + ), + ), + ); + binding.allowFirstFrame(); +} + +Future _applyInitialNativeHeaderBarTheme({ + required AppSettings settings, + required GtkFontSettings? gtkFont, + required GtkThemeColors? gtkThemeColors, +}) async { + final platformDispatcher = WidgetsBinding.instance.platformDispatcher; + final brightness = switch (settings.themeModePreference) { + BusyMaxThemeModePreference.system => platformDispatcher.platformBrightness, + BusyMaxThemeModePreference.light => Brightness.light, + BusyMaxThemeModePreference.dark => Brightness.dark, + }; + final highContrast = platformDispatcher.accessibilityFeatures.highContrast; + final theme = buildBusyMaxTheme( + brightness: brightness, + accentColor: gtkThemeColors?.accent ?? SystemTheme.accentColor.accent, + family: settings.themeFamily, + gtkFontFamily: gtkFont?.family, + gtkFontSize: gtkFont?.size, + gtkThemeColors: gtkThemeColors, + highContrast: highContrast, + ); + final headerBarService = LinuxHeaderBarService(); + try { + await headerBarService.initialize(); + await headerBarService.setTheme( + busyMaxHeaderBarThemeFor(theme, highContrast: highContrast), + ); + } finally { + headerBarService.dispose(); + } +} diff --git a/lib/main_windows.dart b/lib/main_windows.dart new file mode 100644 index 00000000..c7f89ada --- /dev/null +++ b/lib/main_windows.dart @@ -0,0 +1,276 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:system_theme/system_theme.dart'; +import 'package:logging/logging.dart'; + +import 'src/app/app_bootstrap.dart'; +import 'src/app/windows/windows_busymax_app.dart'; +import 'src/config/build_config.dart'; +import 'src/core/logging/redacting_logger.dart'; +import 'src/core/secrets/secret_store.dart'; +import 'src/demo/demo_profile.dart'; +import 'src/features/notifications/desktop_notification_backend.dart'; +import 'src/platform/common/desktop_services.dart'; +import 'src/platform/windows/windows_activation_service.dart'; +import 'src/platform/windows/windows_autostart_service.dart'; +import 'src/platform/windows/windows_notification_backend.dart'; +import 'src/platform/windows/windows_secret_storage_presentation.dart'; +import 'src/platform/windows/windows_system_appearance_source.dart'; +import 'src/platform/windows/windows_time_zone_source.dart'; +import 'src/platform/windows/windows_window_service.dart'; + +Future main(List arguments) async { + final binding = WidgetsFlutterBinding.ensureInitialized(); + binding.deferFirstFrame(); + // Platform initialization failures must be captured, but only through the + // repository's redacting logger. + configureLogging(); + final buildConfig = BuildConfig.fromEnvironment(); + if (arguments.contains(busyMaxNotificationForwarderArgument)) { + await _runNotificationActivationForwarder(buildConfig); + return; + } + final LocalSettingsStore settingsStore; + if (buildConfig.useFakeProviderData) { + final settings = busyMaxDemoSettings(buildConfig.demoTheme); + settingsStore = InMemoryLocalSettingsStore(settings.toJson()); + } else { + settingsStore = const JsonFileLocalSettingsStore(); + } + + await SystemTheme.accentColor.load(); + final initialSettings = await loadInitialAppSettings(settingsStore); + final timeZoneSource = WindowsLocalTimeZoneSource(); + final localTimeZone = await timeZoneSource.currentIanaTimeZone(); + final activationService = WindowsActivationService(); + await activationService.initialize(); + final appearanceSource = WindowsSystemAppearanceSource(); + DesktopNotificationBackend notificationBackend = + const DisabledDesktopNotificationBackend(); + DesktopNotificationReadiness? notificationReadiness; + bool packaged; + try { + packaged = await WindowsNotificationBackend.hasPackageIdentity(); + } on Object { + packaged = false; + final installedFailure = buildConfig.windowsStoreMode; + notificationReadiness = DesktopNotificationReadiness( + installedFailure + ? DesktopNotificationReadinessState.failedInstalledPackage + : DesktopNotificationReadinessState.unavailableUnpackaged, + diagnosticCode: 'windows-notifications/package-identity-query-failed', + ); + Logger( + 'busymax.windows.notifications', + ).severe('Windows notification package identity could not be queried.'); + } + if (notificationReadiness != null) { + // The failed identity probe above already selected the only safe backend. + } else if (!packaged) { + final installedFailure = buildConfig.windowsStoreMode; + notificationReadiness = DesktopNotificationReadiness( + installedFailure + ? DesktopNotificationReadinessState.failedInstalledPackage + : DesktopNotificationReadinessState.unavailableUnpackaged, + diagnosticCode: installedFailure + ? 'windows-notifications/package-identity-missing' + : 'windows-notifications/unpackaged', + ); + final logger = Logger('busymax.windows.notifications'); + if (installedFailure) { + logger.severe( + 'Windows notification package identity is missing from a Store build.', + ); + } else { + logger.warning( + 'Windows notifications are unavailable without package identity.', + ); + } + } else if (buildConfig.windowsAppUserModelId.trim().isEmpty) { + notificationReadiness = const DesktopNotificationReadiness( + DesktopNotificationReadinessState.failedInstalledPackage, + diagnosticCode: 'windows-notifications/aumid-missing', + ); + Logger( + 'busymax.windows.notifications', + ).severe('Windows notification initialization failed: AUMID is missing.'); + } else { + try { + notificationBackend = await WindowsNotificationBackend.create( + appUserModelId: buildConfig.windowsAppUserModelId, + onActivation: activationService.accept, + ); + notificationReadiness = const DesktopNotificationReadiness( + DesktopNotificationReadinessState.available, + ); + } on WindowsNotificationInitializationException catch (error) { + notificationReadiness = DesktopNotificationReadiness( + DesktopNotificationReadinessState.failedInstalledPackage, + diagnosticCode: 'windows-notifications/${error.code}', + ); + Logger( + 'busymax.windows.notifications', + ).severe('Windows notification initialization failed: ${error.code}.'); + notificationBackend = const DisabledDesktopNotificationBackend(); + } on Object { + notificationReadiness = const DesktopNotificationReadiness( + DesktopNotificationReadinessState.failedInstalledPackage, + diagnosticCode: 'windows-notifications/initialization-failed', + ); + Logger( + 'busymax.windows.notifications', + ).severe('Windows notification initialization failed.'); + notificationBackend = const DisabledDesktopNotificationBackend(); + } + } + + final overrides = [ + buildConfigProvider.overrideWithValue(buildConfig), + localSettingsStoreProvider.overrideWithValue(settingsStore), + secretStoreProvider.overrideWith( + (ref) => SecureSecretStore( + ref.watch(secureStorageProvider), + presentation: windowsSecretStoragePresentation, + ), + ), + initialAppSettingsProvider.overrideWithValue(initialSettings), + desktopWindowServiceProvider.overrideWithValue( + const WindowsWindowService(), + ), + desktopAutostartServiceProvider.overrideWithValue( + const WindowsAutostartService(), + ), + desktopActivationServiceProvider.overrideWith((ref) { + ref.onDispose(() => unawaited(activationService.dispose())); + return activationService; + }), + desktopNotificationBackendProvider.overrideWith((ref) { + ref.onDispose(() => unawaited(notificationBackend.close())); + return notificationBackend; + }), + desktopNotificationReadinessProvider.overrideWith( + (ref) => notificationReadiness!, + ), + systemAppearanceSourceProvider.overrideWith((ref) { + ref.onDispose(() => unawaited(appearanceSource.dispose())); + return appearanceSource; + }), + localTimeZoneSourceProvider.overrideWithValue(timeZoneSource), + localTimeZoneProvider.overrideWithValue(localTimeZone), + ]; + final demoProfile = buildConfig.useFakeProviderData + ? await BusyMaxDemoProfile.create() + : null; + + runApp( + ProviderScope( + overrides: [...overrides, ...?demoProfile?.overrides], + child: WindowsBusyMaxApp( + startMinimizedAtLaunch: + arguments.contains(busyMaxStartMinimizedArgument) || + arguments.contains(busyMaxNotificationActivationServerArgument), + ), + ), + ); + binding.allowFirstFrame(); +} + +Future _runNotificationActivationForwarder( + BuildConfig buildConfig, +) async { + final logger = Logger('busymax.windows.notification_forwarder'); + final completed = Completer(); + var finishing = false; + WindowsNotificationBackend? backend; + Timer? activationTimeout; + const windowService = WindowsWindowService(); + + Future reportFailure() async { + try { + await windowService.reportNotificationActivationFailure(); + } on Object { + logger.severe('Notification activation failure could not be displayed.'); + } + } + + Future finish([DesktopActivation? activation]) async { + if (finishing) return; + finishing = true; + activationTimeout?.cancel(); + try { + if (activation != null) { + final forwarded = await forwardWindowsActivationToPrimary(activation); + if (!forwarded) { + logger.severe('Notification activation forwarding failed.'); + exitCode = 75; + await reportFailure(); + } + } + await backend?.close(); + } finally { + if (!completed.isCompleted) completed.complete(); + await windowService.quitApp(); + } + } + + final bool packaged; + try { + packaged = await WindowsNotificationBackend.hasPackageIdentity(); + } on Object { + logger.severe('Notification package identity could not be queried.'); + exitCode = 70; + await reportFailure(); + await finish(); + return; + } + if (!packaged) { + logger.severe( + 'Notification activation cannot run without package identity.', + ); + exitCode = 78; + await reportFailure(); + await finish(); + return; + } + if (buildConfig.windowsAppUserModelId.trim().isEmpty) { + logger.severe('Notification activation cannot run without an AUMID.'); + exitCode = 78; + await reportFailure(); + await finish(); + return; + } + try { + backend = await WindowsNotificationBackend.create( + appUserModelId: buildConfig.windowsAppUserModelId, + onActivation: (activation) => unawaited(finish(activation)), + activationForwarderOnly: true, + ); + } on WindowsNotificationInitializationException catch (error) { + logger.severe( + 'Notification activation initialization failed: ${error.code}.', + ); + exitCode = 70; + await reportFailure(); + await finish(); + return; + } on Object { + logger.severe('Notification activation initialization failed.'); + exitCode = 70; + await reportFailure(); + await finish(); + return; + } + activationTimeout = Timer(const Duration(seconds: 15), () { + logger.severe('Notification activation timed out.'); + exitCode = 75; + unawaited(() async { + await reportFailure(); + await finish(); + }()); + }); + await completed.future; + activationTimeout.cancel(); +} diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index d267e35b..207f84de 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'package:drift/drift.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -8,7 +7,7 @@ import 'package:http/http.dart' as http; import 'package:uuid/uuid.dart'; import '../config/build_config.dart'; -import '../core/time/local_time_zone.dart'; +import '../calendar_providers/cloud_calendar_client.dart'; import '../db/app_database.dart'; import '../dav/auth/dav_account_onboarding_service.dart'; import '../dav/auth/nextcloud_app_password_revoker.dart'; @@ -17,16 +16,20 @@ import '../dav/dav_provider_profile.dart'; import '../dav/discovery/dav_discovery_service.dart'; import '../dav/http/dav_http_transport.dart'; import '../dav/mutation/dav_conflict_repository.dart'; +import '../dav/mutation/dav_calendar_collection_mutation_service.dart'; import '../dav/mutation/dav_pending_operations.dart'; import '../dav/mutation/dav_task_list_mutation_service.dart'; import '../dav/sync/dav_account_sync_engine.dart'; import '../dav/storage/dav_settings_repository.dart'; import '../features/calendar/data/calendar_repository.dart'; +import '../features/calendar/data/calendar_collection_creation_service.dart'; +import '../ical/ical_import_service.dart'; import '../features/accounts/data/accounts_repository.dart'; import '../features/auth/data/auth_repository.dart'; import '../features/connectivity/network_connectivity_service.dart'; import '../features/feedback/data/feedback_api_client.dart'; import '../features/notifications/desktop_notification_service.dart'; +import '../features/notifications/notification_schedule_service.dart'; import '../features/notifications/notification_scheduler.dart'; import '../features/sync/account_sync_operations.dart'; import '../features/sync/all_accounts_sync_scheduler.dart'; @@ -38,29 +41,28 @@ import '../features/sync/sync_failure_notification_policy.dart'; import '../features/task_lists/data/task_lists_repository.dart'; import '../features/tasks/data/tasks_repository.dart'; import '../features/tasks/domain/task_remote_client.dart'; +import '../features/tray/data/tray_presentation_service.dart'; import '../google_tasks/api/google_tasks_api_client.dart'; import '../google_tasks/http/authenticated_http_client.dart'; import '../google_tasks/http/retrying_http_client.dart'; import '../google_tasks/oauth/oauth_loopback_flow.dart'; import '../google_tasks/oauth/oauth_service.dart'; import 'package:busymax/src/core/secrets/secret_store.dart'; -import 'package:busymax/src/core/secrets/portal_encrypted_secret_store.dart'; import '../google_calendar/google_calendar_api_client.dart'; import '../microsoft_calendar/microsoft_calendar_api_client.dart'; import '../microsoft_todo/api/microsoft_todo_api_client.dart'; import '../microsoft_todo/api/microsoft_todo_task_remote_client.dart'; import '../microsoft_todo/oauth/microsoft_oauth_service.dart'; -import '../platform/linux_window_service.dart'; -import '../platform/linux_autostart_service.dart'; +import '../platform/common/desktop_services.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import '../schedule/schedule_commands.dart'; import '../schedule/schedule_repository.dart'; -import 'app_router.dart'; +import '../webcal/webcal_http_client.dart'; +import '../webcal/webcal_subscription_service.dart'; import 'app_settings.dart'; export '../app/app_settings.dart'; -export '../platform/linux_header_bar_provider.dart'; final buildConfigProvider = Provider( (ref) => BuildConfig.fromEnvironment(), @@ -188,11 +190,6 @@ final feedbackSubmissionServiceProvider = Provider(( }); final secretStoreProvider = Provider((ref) { - if (Platform.isLinux && (Platform.environment['SNAP']?.isNotEmpty ?? false)) { - // flutter_secure_storage_linux warms up direct libsecret first, so - // SECRET_BACKEND=file cannot avoid a locked keyring inside strict snaps. - return PortalEncryptedSecretStore(); - } return SecureSecretStore(ref.watch(secureStorageProvider)); }); @@ -233,15 +230,14 @@ final authenticatedHttpClientProvider = Provider((ref) { }); final desktopNotificationBackendProvider = Provider( - (ref) { - final backend = FreedesktopNotificationBackend(); - ref.onDispose(() { - unawaited(backend.close()); - }); - return backend; - }, + (ref) => const DisabledDesktopNotificationBackend(), ); +final desktopNotificationReadinessProvider = + StateProvider( + (ref) => const DesktopNotificationReadiness.initializing(), + ); + final desktopNotificationServiceProvider = Provider( (ref) { final settings = ref.watch(appSettingsControllerProvider); @@ -253,18 +249,50 @@ final desktopNotificationServiceProvider = Provider( }, ); -final linuxWindowServiceProvider = Provider( - (ref) => const LinuxWindowService(), +final desktopWindowServiceProvider = Provider( + (ref) => const NoOpDesktopWindowService(), +); + +/// Sanitized platform adapter health only; never contains menu labels or user +/// content. +final desktopTrayDiagnosticProvider = StateProvider((ref) => null); + +final linuxWindowServiceProvider = desktopWindowServiceProvider; + +final desktopActivationServiceProvider = Provider( + (ref) => const NoOpDesktopActivationService(), +); + +final externalCalendarOpenServiceProvider = desktopActivationServiceProvider; + +final desktopAutostartServiceProvider = Provider( + (ref) => const UnavailableDesktopAutostartService(), ); -final linuxAutostartServiceProvider = Provider( - (ref) => LinuxAutostartService(), +final linuxAutostartServiceProvider = desktopAutostartServiceProvider; + +final launchAtLoginStateProvider = FutureProvider( + (ref) => ref.watch(desktopAutostartServiceProvider).state(), ); final launchAtLoginEnabledProvider = FutureProvider( - (ref) => ref.watch(linuxAutostartServiceProvider).isEnabled(), + (ref) async => + await ref.watch(launchAtLoginStateProvider.future) == + DesktopAutostartState.enabled, ); +final desktopNavigationServiceProvider = Provider(( + ref, +) { + final service = QueuedDesktopNavigationService(); + ref.onDispose(() => unawaited(service.dispose())); + return service; +}); + +final systemAppearanceSourceProvider = Provider((ref) { + throw StateError('A platform SystemAppearanceSource was not configured.'); +}); + final authRepositoryProvider = Provider((ref) { return AuthRepository( oAuth: ref.watch(applicationOAuthGatewayProvider), @@ -334,7 +362,9 @@ final davTaskCollectionCapabilitiesProvider = final selectedAccountIdProvider = StateProvider((ref) => null); final selectedAccountProvider = Provider((ref) { - final accounts = ref.watch(accountsStreamProvider).valueOrNull ?? const []; + final accounts = (ref.watch(accountsStreamProvider).valueOrNull ?? const []) + .where((account) => account.isTaskCapable) + .toList(growable: false); final selectedId = ref.watch(selectedAccountIdProvider); if (selectedId != null) { for (final account in accounts) { @@ -357,7 +387,11 @@ final selectedAccountCapabilitiesProvider = : adapterDefaultTaskCapabilities(account.provider); }); -final localTimeZoneProvider = Provider((ref) => localIanaTimeZone()); +final localTimeZoneSourceProvider = Provider( + (ref) => const FixedLocalTimeZoneSource('Etc/UTC'), +); + +final localTimeZoneProvider = Provider((ref) => 'Etc/UTC'); final googleTasksApiClientForAccountProvider = Provider.family((ref, accountId) { @@ -448,7 +482,33 @@ final taskRemoteApiClientForAccountProvider = BusyProvider.google => ref.watch( googleTasksApiClientForAccountProvider(accountId), ), - BusyProvider.appleICloud || BusyProvider.nextcloud => null, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, + }; + }); + +final calendarRemoteApiClientForAccountProvider = + Provider.family((ref, accountId) { + final accounts = ref.watch(accountsStreamProvider).valueOrNull; + AccountEntity? account; + for (final candidate in accounts ?? const []) { + if (candidate.id == accountId) { + account = candidate; + break; + } + } + if (account == null) return null; + return switch (account.provider) { + BusyProvider.google => ref.watch( + googleCalendarApiClientForAccountProvider(accountId), + ), + BusyProvider.microsoft => ref.watch( + microsoftCalendarApiClientForAccountProvider(accountId), + ), + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, }; }); @@ -469,6 +529,9 @@ final syncEngineForAccountFactoryProvider = BusyProvider.nextcloud => throw StateError( 'DAV task accounts must use DavSynchronizationEngine.', ), + BusyProvider.webCal => throw StateError( + 'WebCal subscriptions do not have a task client.', + ), }; return SyncEngine( @@ -500,6 +563,9 @@ final calendarSyncEngineForAccountFactoryProvider = BusyProvider.nextcloud => throw StateError( 'DAV calendar accounts must use DavSynchronizationEngine.', ), + BusyProvider.webCal => throw StateError( + 'WebCal subscriptions use WebCalSubscriptionService.', + ), }; return CalendarSyncEngine( database: ref.read(databaseProvider), @@ -524,14 +590,22 @@ final davAccountSyncEngineFactoryProvider = secretStore: ref.read(secretStoreProvider), httpClient: ref.read(baseHttpClientProvider), accountId: accountId, - rebuildNotifications: (accountId, affectedObjectIds) => - ref.read(notificationSchedulerProvider).checkNow(), + rebuildNotifications: (accountId, _) async { + await NotificationScheduleService( + database: ref.read(databaseProvider), + ).rebuildUpcomingNotifications(accountId); + await ref.read(notificationSchedulerProvider).checkNow(); + }, reportPendingMutationFailure: (accountId, error) => ref .read(desktopNotificationServiceProvider) .notifySyncFailure(error), ); }); +final accountSyncCoordinatorProvider = Provider((ref) { + return AccountSyncCoordinator(); +}); + final accountSyncOperationsProvider = Provider((ref) { final accountsRepository = ref.watch(accountsRepositoryProvider); final connectivity = ref.watch(networkConnectivityMonitorProvider); @@ -545,16 +619,15 @@ final accountSyncOperationsProvider = Provider((ref) { } final routing = RoutingAccountSyncOperations( - usesDav: (accountId) async { - final provider = await providerForAccount(accountId); - return provider == BusyProvider.appleICloud || - provider == BusyProvider.nextcloud; - }, + providerForAccount: providerForAccount, syncDav: (accountId, {required full}) async { await ref .read(davAccountSyncEngineFactoryProvider)(accountId) .synchronize(full: full); }, + syncWebCal: (accountId, {required full}) => ref + .read(webCalSubscriptionServiceProvider) + .refreshAccount(accountId, force: full), syncTasksRest: (accountId, {required full}) async { final provider = await providerForAccount(accountId); final engine = ref.read(syncEngineForAccountFactoryProvider)( @@ -580,9 +653,12 @@ final accountSyncOperationsProvider = Provider((ref) { } }, ); - return ConnectivityAwareAccountSyncOperations( - inner: routing, - requireNetwork: connectivity.requireNetwork, + return CoordinatedAccountSyncOperations( + coordinator: ref.watch(accountSyncCoordinatorProvider), + inner: ConnectivityAwareAccountSyncOperations( + inner: routing, + requireNetwork: connectivity.requireNetwork, + ), ); }); @@ -613,8 +689,8 @@ final allAccountsSyncRunnerProvider = Provider((ref) { return () async { await ref.read(networkConnectivityMonitorProvider).requireNetwork(); - await runAllSignedInAccountSync( - listSignedInAccounts: ref + await runAllSyncEligibleAccountSync( + listSyncEligibleAccounts: ref .read(accountsRepositoryProvider) .listSyncEligibleAccounts, syncAccount: syncAccount, @@ -647,8 +723,44 @@ final calendarRepositoryProvider = Provider((ref) { ); }); +final webCalHttpTransportProvider = Provider( + (ref) => IoWebCalHttpTransport(), +); + +final webCalSubscriptionServiceProvider = Provider(( + ref, +) { + return WebCalSubscriptionService( + database: ref.watch(databaseProvider), + secretStore: ref.watch(secretStoreProvider), + httpTransport: ref.watch(webCalHttpTransportProvider), + onNotificationScheduleChanged: () => + ref.read(notificationSchedulerProvider).checkNow(), + ); +}); + +final webCalSubscriptionsProvider = + StreamProvider>((ref) { + return ref.watch(webCalSubscriptionServiceProvider).watchSubscriptions(); + }); + +final icalImportServiceProvider = Provider((ref) { + return IcalImportService( + database: ref.watch(databaseProvider), + calendarRepository: ref.watch(calendarRepositoryProvider), + ); +}); + final scheduleRepositoryProvider = Provider((ref) { - return ScheduleRepository(ref.watch(databaseProvider)); + return ScheduleRepository( + ref.watch(databaseProvider), + ensureProjectionCoverage: (range) => ref + .read(webCalSubscriptionServiceProvider) + .ensureProjectionCoverage( + rangeStartUtc: range.start.toUtc(), + rangeEndUtc: range.end.toUtc(), + ), + ); }); final Provider activeAccountProvider = Provider((ref) { @@ -678,6 +790,44 @@ final davTaskListMutationClientForAccountProvider = refreshAfterMutation: () => ref .read(accountSyncOperationsProvider) .syncAccount(accountId, full: true), + requireNetwork: ref + .watch(networkConnectivityMonitorProvider) + .requireNetwork, + ); + }); + +final davCalendarCollectionMutationClientForAccountProvider = + Provider.family(( + ref, + accountId, + ) { + return DavCalendarCollectionMutationService( + database: ref.watch(databaseProvider), + secretStore: ref.watch(secretStoreProvider), + httpClient: ref.watch(baseHttpClientProvider), + accountId: accountId, + refreshAfterMutation: () => ref + .read(accountSyncOperationsProvider) + .syncAccount(accountId, full: true), + requireNetwork: ref + .watch(networkConnectivityMonitorProvider) + .requireNetwork, + ); + }); + +final calendarCollectionCreationServiceProvider = + Provider((ref) { + return CalendarCollectionCreationService( + accountsRepository: ref.watch(accountsRepositoryProvider), + calendarRepository: ref.watch(calendarRepositoryProvider), + davClientForAccount: (accountId) => ref.read( + davCalendarCollectionMutationClientForAccountProvider(accountId), + ), + requestCloudSynchronization: (accountId) => ref + .read( + pendingCalendarMutationSyncRequesterForAccountProvider(accountId), + ) + .request(), ); }); @@ -694,6 +844,8 @@ final taskListsRepositoryProvider = Provider((ref) { davTaskListMutationClientForAccountProvider(accountId), ), onMutationQueued: ref.watch(pendingMutationSyncRequesterProvider)?.request, + onNotificationScheduleChanged: () => + ref.read(notificationSchedulerProvider).checkNow(), ); }); @@ -709,6 +861,8 @@ final taskListsRepositoryForAccountProvider = onMutationQueued: ref .watch(pendingMutationSyncRequesterForAccountProvider(accountId)) .request, + onNotificationScheduleChanged: () => + ref.read(notificationSchedulerProvider).checkNow(), ); }); @@ -741,24 +895,6 @@ final tasksRepositoryForAccountProvider = ); }); -final Provider syncEngineProvider = Provider((ref) { - final accountId = ref.watch(activeAccountProvider); - final apiClient = ref.watch(googleTasksApiClientProvider); - final account = ref.watch(selectedAccountProvider); - if (accountId == null || apiClient == null || account?.id != accountId) { - return null; - } - return SyncEngine( - database: ref.watch(databaseProvider), - apiClient: apiClient, - accountId: accountId, - fullRefreshOnly: account!.provider == BusyProvider.microsoft, - onConflictBlocked: ref - .watch(desktopNotificationServiceProvider) - .notifyConflict, - ); -}); - final pendingMutationSyncRequesterProvider = Provider((ref) { final accountId = ref.watch(activeAccountProvider); @@ -798,19 +934,42 @@ final pendingMutationSyncRequesterForAccountProvider = return requester; }); +final pendingCalendarMutationSyncRequesterForAccountProvider = + Provider.family((ref, accountId) { + final requester = PendingMutationSyncRequester( + sync: () => ref + .read(accountSyncOperationsProvider) + .syncCalendar(accountId, full: false), + onSyncFailure: ref + .watch(desktopNotificationServiceProvider) + .notifySyncFailure, + onSyncError: (error) => + _markAccountReconnectRequiredForSyncError(ref, accountId, error), + canSync: ref.watch(networkConnectivityMonitorProvider).canUseNetwork, + ); + ref.onDispose(requester.dispose); + return requester; + }); + final pendingOpResolutionServiceProvider = Provider((ref) { final accountId = ref.watch(activeAccountProvider); - final apiClient = ref.watch(googleTasksApiClientProvider); - final syncEngine = ref.watch(syncEngineProvider); - if (accountId == null || apiClient == null || syncEngine == null) { + if (accountId == null) { return null; } return PendingOpResolutionService( database: ref.watch(databaseProvider), - apiClient: apiClient, + apiClient: ref.watch(googleTasksApiClientProvider), + calendarClient: ref.watch( + calendarRemoteApiClientForAccountProvider(accountId), + ), accountId: accountId, - syncEngine: syncEngine, + syncTasks: () => ref + .read(accountSyncOperationsProvider) + .syncTasks(accountId, full: false), + syncCalendar: () => ref + .read(accountSyncOperationsProvider) + .syncCalendar(accountId, full: false), ); }); @@ -822,9 +981,9 @@ final syncSchedulerProvider = Provider((ref) { } final scheduler = AllAccountsSyncScheduler( - listSignedInAccounts: ref + listSyncEligibleAccounts: ref .read(accountsRepositoryProvider) - .listSignedInAccounts, + .listSyncEligibleAccounts, syncAccount: syncAccount, onSyncFailure: ref .watch(desktopNotificationServiceProvider) @@ -834,15 +993,55 @@ final syncSchedulerProvider = Provider((ref) { canSync: ref.watch(networkConnectivityMonitorProvider).canUseNetwork, ); scheduler.start(); - ref.onDispose(scheduler.stop); + ref.onDispose(() => unawaited(scheduler.dispose())); return scheduler; }); +final syncSchedulerRunningProvider = StreamProvider((ref) { + return ref.watch(syncSchedulerProvider).watchRunning(); +}); + +final trayPresentationServiceProvider = + Provider((ref) { + return BusyMaxTrayPresentationService( + accountsRepository: ref.watch(accountsRepositoryProvider), + calendarRepository: ref.watch(calendarRepositoryProvider), + scheduleRepository: ref.watch(scheduleRepositoryProvider), + listTaskLists: (account) => ref + .read(taskListsRepositoryForAccountProvider(account.id)) + .listTaskLists(), + canCreateTask: (account, taskList) async { + if (account.provider == BusyProvider.google || + account.provider == BusyProvider.microsoft) { + return adapterDefaultTaskCapabilities( + account.provider, + ).canCreateTasks; + } + if (account.provider != BusyProvider.nextcloud) return false; + final capabilities = await ref.read( + davTaskCollectionCapabilitiesProvider(( + accountId: account.id, + taskListId: taskList.id, + )).future, + ); + return capabilities?.canCreateTasks == true; + }, + settings: () => ref.read(appSettingsControllerProvider), + connectivity: () => + ref.read(networkConnectivityMonitorProvider).availability, + synchronizationRunning: () => ref.read(syncSchedulerProvider).isRunning, + ); + }); + Future _markAccountReconnectRequiredForSyncError( Ref ref, String accountId, Object error, ) async { + final account = await ref + .read(accountsRepositoryProvider) + .accountById(accountId); + if (account?.isAuthenticationAccount != true) return; if (syncFailureNotificationDisposition(error) != SyncFailureNotificationDisposition.reconnectRequired) { return; @@ -874,7 +1073,7 @@ Future _openNotificationSource( return switch (row.sourceType) { 'event' => _openEventNotification(ref, row), 'task' => _openTaskNotification(ref, row), - _ => ref.read(linuxWindowServiceProvider).showWindow(), + _ => ref.read(desktopWindowServiceProvider).showWindow(), }; } @@ -893,7 +1092,7 @@ Future _openEventNotification( )) .getSingleOrNull(); if (event == null) { - await ref.read(linuxWindowServiceProvider).showWindow(); + await ref.read(desktopWindowServiceProvider).showWindow(); return; } @@ -923,7 +1122,7 @@ Future _openTaskNotification( ..limit(1)) .getSingleOrNull(); if (task == null) { - await ref.read(linuxWindowServiceProvider).showWindow(); + await ref.read(desktopWindowServiceProvider).showWindow(); return; } @@ -945,7 +1144,7 @@ Future _openScheduleItemFromNotification( required String sourceId, required String itemId, }) async { - await ref.read(linuxWindowServiceProvider).showWindow(); + await ref.read(desktopWindowServiceProvider).showWindow(); final sequenceController = ref.read( _notificationOpenSequenceProvider.notifier, ); @@ -961,7 +1160,9 @@ Future _openScheduleItemFromNotification( sourceId: sourceId, itemId: itemId, ); - ref.read(appRouterProvider).go('/schedule'); + ref + .read(desktopNavigationServiceProvider) + .open(DesktopNavigationDestination.schedule); } DateTime? _calendarEventCommandDate(CalendarEvent event) { diff --git a/lib/src/app/app_router.dart b/lib/src/app/app_router.dart index 2891ffb8..2b28b828 100644 --- a/lib/src/app/app_router.dart +++ b/lib/src/app/app_router.dart @@ -17,12 +17,15 @@ final rootNavigatorKey = GlobalKey(); final appRouterProvider = Provider((ref) { final session = ref.watch(authSessionControllerProvider); + final hasSubscriptions = + ref.watch(webCalSubscriptionsProvider).valueOrNull?.isNotEmpty == true; + final canOpenSchedule = session.isSignedIn || hasSubscriptions; return GoRouter( navigatorKey: rootNavigatorKey, initialLocation: session.status == AuthSessionStatus.loading ? '/' - : session.isSignedIn + : canOpenSchedule ? '/schedule' : '/sign-in', redirect: (context, state) { @@ -31,16 +34,16 @@ final appRouterProvider = Provider((ref) { } if (state.matchedLocation == '/') { - return session.isSignedIn ? '/schedule' : '/sign-in'; + return canOpenSchedule ? '/schedule' : '/sign-in'; } - if (!session.isSignedIn && state.matchedLocation != '/sign-in') { + if (!canOpenSchedule && + state.matchedLocation != '/sign-in' && + state.matchedLocation != '/settings') { return '/sign-in'; } - if (session.isSignedIn && - session.status == AuthSessionStatus.signedIn && - state.matchedLocation == '/sign-in') { + if (canOpenSchedule && state.matchedLocation == '/sign-in') { return '/schedule'; } return null; diff --git a/lib/src/app/busymax_about_dialog.dart b/lib/src/app/busymax_about_dialog.dart index 0fa8cbc2..5edb7cdc 100644 --- a/lib/src/app/busymax_about_dialog.dart +++ b/lib/src/app/busymax_about_dialog.dart @@ -10,7 +10,6 @@ import '../platform/linux_header_bar_service.dart'; import 'busymax_design.dart'; import 'busymax_dialog_identity.dart'; import 'busymax_dialogs.dart'; -import 'busymax_surface_colors.dart'; const _busyMaxWebsiteUrl = 'https://busystack.org'; const _busyMaxRepositoryUrl = 'https://github.com/busystack/busymax/'; @@ -142,13 +141,13 @@ class _VersionTag extends StatelessWidget { return const SizedBox.shrink(); } final theme = Theme.of(context); - final colors = BusyMaxSurfaceColors.of(context); + final colorScheme = theme.colorScheme; return Center( child: DecoratedBox( decoration: BoxDecoration( - color: colors.control, + color: colorScheme.primary, borderRadius: BorderRadius.circular(BusyMaxRadius.pill), - border: Border.all(color: colors.divider), + border: Border.all(color: colorScheme.primary), ), child: Padding( padding: const EdgeInsets.symmetric( @@ -163,7 +162,7 @@ class _VersionTag extends StatelessWidget { overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: theme.textTheme.labelMedium?.copyWith( - color: colors.foreground, + color: colorScheme.onPrimary, fontWeight: FontWeight.w600, ), ), diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 1f0ba324..b99eb057 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -6,11 +6,13 @@ import 'package:system_theme/system_theme.dart'; import 'package:ubuntu_localizations/ubuntu_localizations.dart'; import '../platform/busymax_tray_service.dart'; -import '../features/connectivity/network_connectivity_service.dart'; +import '../features/tray/domain/tray_presentation.dart'; +import '../features/tray/domain/tray_presentation_formatter.dart'; import '../platform/gtk_font_service.dart'; import '../platform/linux_header_bar_configuration_synchronizer.dart'; +import '../platform/linux_header_bar_provider.dart'; import '../platform/linux_header_bar_service.dart'; -import '../platform/linux_window_service.dart'; +import '../platform/common/desktop_services.dart'; import '../l10n/locale_resolution.dart'; import '../schedule/schedule_commands.dart'; import 'app_bootstrap.dart'; @@ -22,13 +24,11 @@ import 'busymax_yaru_theme.dart'; import 'busymax_design.dart'; import 'system_accent.dart'; import 'app_theme.dart'; +import '../features/settings/presentation/settings_screen.dart'; +import '../features/calendar/presentation/ical_import_flow.dart'; typedef BusyMaxTrayServiceFactory = - BusyMaxTrayService Function({ - required LinuxWindowService windowService, - required BusyMaxTrayLabels labels, - required Future Function() onOpenAgenda, - }); + BusyMaxTrayService Function(BusyMaxTrayServiceConfiguration configuration); BusyMaxHeaderBarTheme busyMaxHeaderBarThemeFor( ThemeData theme, { @@ -61,8 +61,8 @@ BusyMaxHeaderBarTheme busyMaxHeaderBarThemeFor( ); } -class BusyMaxApp extends ConsumerStatefulWidget { - const BusyMaxApp({ +class LinuxBusyMaxApp extends ConsumerStatefulWidget { + const LinuxBusyMaxApp({ super.key, this.trayServiceFactory, this.startMinimizedAtLaunch = false, @@ -73,16 +73,28 @@ class BusyMaxApp extends ConsumerStatefulWidget { final bool startMinimizedAtLaunch; @override - ConsumerState createState() => _BusyMaxAppState(); + ConsumerState createState() => _BusyMaxAppState(); } -class _BusyMaxAppState extends ConsumerState { +class BusyMaxApp extends LinuxBusyMaxApp { + const BusyMaxApp({ + super.key, + super.trayServiceFactory, + super.startMinimizedAtLaunch, + }); +} + +class _BusyMaxAppState extends ConsumerState { BusyMaxTrayService? _trayService; bool? _lastHideOnClose; bool? _lastTrayEnabled; bool _startMinimizedHandled = false; bool _settingsReady = false; var _scheduleCommandSequence = 0; + BusyMaxTrayPresentationFormatter? _trayPresentationFormatter; + StreamSubscription? _externalOpenSubscription; + StreamSubscription? _navigationSubscription; + Future _externalOpenTail = Future.value(); late final BusyMaxHeaderBarConfigurationSynchronizer _headerBarConfigurationSynchronizer; @@ -93,12 +105,26 @@ class _BusyMaxAppState extends ConsumerState { BusyMaxHeaderBarConfigurationSynchronizer( ref.read(linuxHeaderBarServiceProvider), ); + _externalOpenSubscription = ref + .read(desktopActivationServiceProvider) + .activations + .listen((request) { + _externalOpenTail = _externalOpenTail + .catchError((Object _) {}) + .then((_) => _handleExternalCalendarOpen(request)); + }); + _navigationSubscription = ref + .read(desktopNavigationServiceProvider) + .requests + .listen(_handleNavigationRequest); unawaited(_waitForSettings()); } @override void dispose() { _headerBarConfigurationSynchronizer.dispose(); + unawaited(_externalOpenSubscription?.cancel()); + unawaited(_navigationSubscription?.cancel()); final tray = _trayService; if (tray != null) { unawaited(tray.stop()); @@ -106,6 +132,51 @@ class _BusyMaxAppState extends ConsumerState { super.dispose(); } + Future _handleExternalCalendarOpen(DesktopActivation request) async { + if (!mounted) return; + final router = ref.read(appRouterProvider); + switch (request.kind) { + case DesktopActivationKind.webCal: + router.go('/settings?page=accounts'); + case DesktopActivationKind.icsFile: + final session = ref.read(authSessionControllerProvider); + router.go(session.isSignedIn ? '/schedule' : '/settings?page=accounts'); + case DesktopActivationKind.normalLaunch: + case DesktopActivationKind.startMinimized: + case DesktopActivationKind.notification: + return; + } + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + final dialogContext = rootNavigatorKey.currentContext; + if (dialogContext == null || !dialogContext.mounted) return; + switch (request.kind) { + case DesktopActivationKind.webCal: + await showAddCalendarSubscriptionFlow( + dialogContext, + ref, + initialUrl: request.value!, + ); + case DesktopActivationKind.icsFile: + await showIcsImportFlow(dialogContext, ref, filePath: request.value!); + case DesktopActivationKind.normalLaunch: + case DesktopActivationKind.startMinimized: + case DesktopActivationKind.notification: + return; + } + } + + void _handleNavigationRequest(DesktopNavigationRequest request) { + if (!mounted) return; + final router = ref.read(appRouterProvider); + router.go(switch (request.destination) { + DesktopNavigationDestination.schedule => '/schedule', + DesktopNavigationDestination.tasks => '/tasks', + DesktopNavigationDestination.settings => '/settings', + DesktopNavigationDestination.signIn => '/sign-in', + }); + } + Future _waitForSettings() async { await ref.read(appSettingsControllerProvider.notifier).ready; if (!mounted) { @@ -123,10 +194,9 @@ class _BusyMaxAppState extends ConsumerState { .valueOrNull; final gtkFont = ref.watch(gtkFontSettingsProvider).valueOrNull; final gtkThemeColors = ref.watch(gtkThemeColorsProvider).valueOrNull; - final networkAvailability = - ref.watch(networkAvailabilityProvider).valueOrNull ?? - ref.read(networkConnectivityMonitorProvider).availability; + ref.watch(networkAvailabilityProvider); ref.watch(syncSchedulerProvider); + ref.watch(syncSchedulerRunningProvider); ref.watch(networkReconnectSyncCoordinatorProvider); ref.watch(notificationSchedulerProvider); ref.watch(dueTodayNotificationProvider); @@ -179,22 +249,47 @@ class _BusyMaxAppState extends ConsumerState { ...GlobalUbuntuLocalizations.delegates, ], localeListResolutionCallback: resolveBusyMaxLocales, - supportedLocales: AppLocalizations.supportedLocales, + supportedLocales: busyMaxSupportedLocales, builder: (context, child) { final l10n = AppLocalizations.of(context); - _configureNativeHeaderBarTheme(context); - _configureBackgroundServices( - ref, - settings, - BusyMaxTrayLabels( - openBusyMax: l10n.trayOpenBusyMax, - agenda: l10n.viewAgenda, - quitBusyMax: l10n.exit, + final material = MaterialLocalizations.of(context); + final alwaysUse24HourFormat = MediaQuery.alwaysUse24HourFormatOf( + context, + ); + final trayFormatter = BusyMaxTrayPresentationFormatter( + BusyMaxTrayPresentationStrings( + showBusyMax: l10n.trayShowBusyMax, + newEvent: l10n.trayNewEvent, + newTask: l10n.trayNewTask, + today: l10n.trayToday, + allDay: l10n.trayAllDay, + now: l10n.trayNow, + calendarEvent: l10n.trayCalendarEvent, + untitledEvent: l10n.trayUntitledEvent, + nothingElseToday: l10n.trayNothingElseToday, + openTodayAgenda: l10n.trayOpenTodayAgenda, + syncNow: l10n.traySyncNow, + syncing: l10n.traySyncing, + notConnected: l10n.trayNotConnected, + notYetSynced: l10n.trayNotYetSynced, + settings: l10n.traySettings, + quitBusyMax: l10n.trayQuitBusyMax, offline: l10n.networkOffline, offlineDescription: l10n.networkOfflineDescription, + formatTime: (value) => material.formatTimeOfDay( + TimeOfDay.fromDateTime(value), + alwaysUse24HourFormat: alwaysUse24HourFormat, + ), + tasksDueToday: l10n.trayTasksDueToday, + lastSyncedJustNow: l10n.trayLastSyncedJustNow, + lastSyncedMinutesAgo: l10n.trayLastSyncedMinutesAgo, + lastSyncedHoursAgo: l10n.trayLastSyncedHoursAgo, + lastSyncedDaysAgo: l10n.trayLastSyncedDaysAgo, ), - offline: networkAvailability == NetworkAvailability.offline, ); + _trayPresentationFormatter = trayFormatter; + _configureNativeHeaderBarTheme(context); + _configureBackgroundServices(ref, settings, trayFormatter); return Shortcuts( shortcuts: const { BusyMaxShortcutActivators.keyboardShortcuts: @@ -300,14 +395,13 @@ class _BusyMaxAppState extends ConsumerState { void _configureBackgroundServices( WidgetRef ref, AppSettings settings, - BusyMaxTrayLabels labels, { - required bool offline, - }) { + BusyMaxTrayPresentationFormatter trayFormatter, + ) { if (!_settingsReady) { return; } - final windowService = ref.read(linuxWindowServiceProvider); + final windowService = ref.read(desktopWindowServiceProvider); final trayEnabled = settings.showTrayIcon || settings.runInBackgroundWhenClosed || @@ -320,13 +414,7 @@ class _BusyMaxAppState extends ConsumerState { (_trayService?.available ?? false), ); if (_trayService != null) { - unawaited( - _updateTrayPresentation( - _trayService!, - labels: labels, - offline: offline, - ), - ); + unawaited(_trayService!.refreshPresentation()); } if (_lastTrayEnabled == trayEnabled) { return; @@ -334,10 +422,10 @@ class _BusyMaxAppState extends ConsumerState { _lastTrayEnabled = trayEnabled; final tray = _trayService ??= _createTrayService( windowService: windowService, - labels: labels, - onOpenAgenda: () => _openMainAgenda(ref, windowService), + formatter: trayFormatter, + settings: settings, ); - unawaited(_updateTrayPresentation(tray, labels: labels, offline: offline)); + unawaited(tray.refreshPresentation()); if (trayEnabled) { unawaited( _startTray( @@ -353,50 +441,128 @@ class _BusyMaxAppState extends ConsumerState { } } - Future _updateTrayPresentation( - BusyMaxTrayService tray, { - required BusyMaxTrayLabels labels, - required bool offline, - }) async { - await tray.updateOfflineState(offline); - await tray.updateLabels(labels); - } - BusyMaxTrayService _createTrayService({ - required LinuxWindowService windowService, - required BusyMaxTrayLabels labels, - required Future Function() onOpenAgenda, + required DesktopWindowService windowService, + required BusyMaxTrayPresentationFormatter formatter, + required AppSettings settings, }) { + final initialPresentation = formatter.format( + BusyMaxTrayPresentation( + connectivity: ref.read(networkConnectivityMonitorProvider).availability, + synchronizationRunning: ref.read(syncSchedulerProvider).isRunning, + lastSuccessfulSynchronizationUtc: null, + events: const [], + incompleteTasksDueToday: 0, + canCreateEvent: false, + canCreateTask: false, + hasSyncEligibleAccount: false, + notificationDetailLevel: settings.notificationDetailLevel, + localNow: DateTime.now(), + ), + ); + final configuration = BusyMaxTrayServiceConfiguration( + initialPresentation: initialPresentation, + loadPresentation: _loadTrayPresentation, + actions: BusyMaxTrayActions( + showBusyMax: windowService.showWindow, + newEvent: () => _openTrayNewEvent(windowService), + newTask: () => _openTrayNewTask(windowService), + openEvent: (event) => _openTrayEvent(windowService, event), + openTasksDueToday: () => _openTrayTasksDueToday(windowService), + openTodayAgenda: () => _openMainAgenda(windowService), + synchronize: () => ref.read(syncSchedulerProvider).runNow(), + openSettings: () => _openTraySettings(windowService), + quitBusyMax: windowService.quitApp, + ), + ); final factory = widget.trayServiceFactory; if (factory != null) { - return factory( - windowService: windowService, - labels: labels, - onOpenAgenda: onOpenAgenda, - ); + return factory(configuration); } - return BusyMaxTrayService( - windowService: windowService, - labels: labels, - onOpenAgenda: onOpenAgenda, + return BusyMaxTrayService(configuration: configuration); + } + + Future _loadTrayPresentation() async { + final formatter = _trayPresentationFormatter; + if (formatter == null) { + throw StateError('Tray localization is not ready.'); + } + final presentation = await ref.read(trayPresentationServiceProvider).load(); + return formatter.format(presentation); + } + + Future _openTrayNewEvent(DesktopWindowService windowService) async { + await windowService.showWindow(); + ref.read(appRouterProvider).go('/schedule'); + _issueScheduleCommand(ScheduleWorkspaceCommandKind.newEvent); + } + + Future _openTrayNewTask(DesktopWindowService windowService) async { + await windowService.showWindow(); + ref.read(appRouterProvider).go('/tasks'); + _issueScheduleCommand(ScheduleWorkspaceCommandKind.newTask); + } + + Future _openTrayEvent( + DesktopWindowService windowService, + BusyMaxTrayEventEntry event, + ) async { + await windowService.showWindow(); + ref.read(appRouterProvider).go('/schedule'); + _issueScheduleCommand( + ScheduleWorkspaceCommandKind.openCalendarEvent, + date: event.start, + accountId: event.accountId, + sourceId: event.calendarSourceId, + itemId: event.eventId, ); } - Future _openMainAgenda( - WidgetRef ref, - LinuxWindowService windowService, + Future _openTrayTasksDueToday( + DesktopWindowService windowService, ) async { await windowService.showWindow(); + ref.read(appRouterProvider).go('/tasks'); + _issueScheduleCommand( + ScheduleWorkspaceCommandKind.agenda, + date: DateTime.now(), + ); + } + + Future _openTraySettings(DesktopWindowService windowService) async { + await windowService.showWindow(); + ref.read(appRouterProvider).go('/settings'); + } + + Future _openMainAgenda(DesktopWindowService windowService) async { + await windowService.showWindow(); + ref.read(appRouterProvider).go('/schedule'); + _issueScheduleCommand( + ScheduleWorkspaceCommandKind.agenda, + date: DateTime.now(), + ); + } + + void _issueScheduleCommand( + ScheduleWorkspaceCommandKind kind, { + DateTime? date, + String? accountId, + String? sourceId, + String? itemId, + }) { ref .read(scheduleWorkspaceCommandProvider.notifier) .state = ScheduleWorkspaceCommand( - ScheduleWorkspaceCommandKind.agenda, + kind, ++_scheduleCommandSequence, + date: date, + accountId: accountId, + sourceId: sourceId, + itemId: itemId, ); - ref.read(appRouterProvider).go('/schedule'); } - void _setHideOnClose(LinuxWindowService windowService, bool enabled) { + void _setHideOnClose(DesktopWindowService windowService, bool enabled) { if (_lastHideOnClose == enabled) { return; } @@ -406,7 +572,7 @@ class _BusyMaxAppState extends ConsumerState { Future _startTray( BusyMaxTrayService tray, - LinuxWindowService windowService, { + DesktopWindowService windowService, { required bool startMinimizedToTray, }) async { await tray.start(); diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 1f27c0fd..4fa19059 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -542,6 +542,7 @@ class BusyMaxHeaderIconButton extends StatelessWidget { this.overlayColor, this.fixedSize, this.shape, + this.focusNode, }); final Widget icon; @@ -553,6 +554,7 @@ class BusyMaxHeaderIconButton extends StatelessWidget { final WidgetStateProperty? overlayColor; final Size? fixedSize; final OutlinedBorder? shape; + final FocusNode? focusNode; @override Widget build(BuildContext context) { @@ -561,6 +563,7 @@ class BusyMaxHeaderIconButton extends StatelessWidget { icon: icon, iconSize: iconSize, onPressed: onPressed, + focusNode: focusNode, style: busyMaxHeaderIconButtonStyle( context, @@ -994,10 +997,12 @@ class BusyMaxPopoverIconButton extends StatelessWidget { @override Widget build(BuildContext context) { final colors = BusyMaxSurfaceColors.of(context); - final foreground = destructive ? Theme.of(context).colorScheme.error : null; + final colorScheme = Theme.of(context).colorScheme; + final foreground = destructive ? colorScheme.onError : null; + final background = destructive ? colorScheme.error : colors.control; final enabled = onPressed != null; return Material( - color: enabled ? colors.control : colors.disabledControl, + color: enabled ? background : colors.disabledControl, shape: const CircleBorder(), child: YaruIconButton( icon: Icon( @@ -2173,6 +2178,7 @@ class BusyMaxComboRow extends StatelessWidget { BusyMaxMenuEntry( value: value, label: labelFor(value), + role: BusyMaxMenuEntryRole.radio, selected: value == selected, ), ], @@ -2336,6 +2342,7 @@ class BusyMaxMenuEntry { this.child, this.shortcut, this.enabled = true, + this.role = BusyMaxMenuEntryRole.command, this.selected = false, this.tooltip, this.destructive = false, @@ -2347,11 +2354,14 @@ class BusyMaxMenuEntry { final Widget? child; final String? shortcut; final bool enabled; + final BusyMaxMenuEntryRole role; final bool selected; final String? tooltip; final bool destructive; } +enum BusyMaxMenuEntryRole { command, radio, toggle } + @immutable final class BusyMaxMenuSelection { const BusyMaxMenuSelection(this.value); @@ -2488,19 +2498,31 @@ Future?> showBusyMaxMenu({ } void _validateBusyMaxMenuEntries(List> entries) { - final selectedCount = entries.where((entry) => entry.selected).length; - if (selectedCount > 1) { + if (entries.any( + (entry) => entry.role == BusyMaxMenuEntryRole.command && entry.selected, + )) { + throw ArgumentError.value( + entries, + 'entries', + 'Command menu entries cannot be selected.', + ); + } + final radioEntries = entries + .where((entry) => entry.role == BusyMaxMenuEntryRole.radio) + .toList(growable: false); + if (radioEntries.isNotEmpty && + radioEntries.where((entry) => entry.selected).length != 1) { throw ArgumentError.value( entries, 'entries', - 'A single-choice menu can have only one selected entry.', + 'A single-choice menu must have exactly one selected radio entry.', ); } - if (selectedCount == 1 && entries.any((entry) => !entry.enabled)) { + if (radioEntries.any((entry) => !entry.enabled)) { throw ArgumentError.value( entries, 'entries', - 'Single-choice menu entries must all be enabled.', + 'Single-choice radio entries must all be enabled.', ); } } @@ -2538,6 +2560,11 @@ List _nativeMenuEntries(List> entries) { label: entry.label, iconName: BusyMaxGlyphs.nativeMenuIconName(entry.icon), enabled: entry.enabled, + role: switch (entry.role) { + BusyMaxMenuEntryRole.command => NativeMenuEntryRole.command, + BusyMaxMenuEntryRole.radio => NativeMenuEntryRole.radio, + BusyMaxMenuEntryRole.toggle => NativeMenuEntryRole.toggle, + }, selected: entry.selected, shortcut: entry.shortcut, ), @@ -2577,8 +2604,9 @@ Future _showBusyMaxFlutterMenu({ localAnchor.width, 0, ); - final hasSelectedEntry = entries.any((entry) => entry.selected); - final selectedIndex = entries.indexWhere((entry) => entry.selected); + final selectedRadioIndex = entries.indexWhere( + (entry) => entry.role == BusyMaxMenuEntryRole.radio && entry.selected, + ); final firstEnabledIndex = entries.indexWhere((entry) => entry.enabled); final firstEnabledKey = focusFirst && firstEnabledIndex >= 0 ? GlobalKey() @@ -2597,18 +2625,28 @@ Future _showBusyMaxFlutterMenu({ child: _busyMaxFocusableFallbackEntry( context, entries[index], - selectionIndicator: hasSelectedEntry - ? ExcludeSemantics( - child: IgnorePointer( - child: YaruRadio( - value: index, - groupValue: selectedIndex, - onChanged: (_) {}, - hasFocusBorder: false, - ), - ), - ) - : null, + selectionIndicator: switch (entries[index].role) { + BusyMaxMenuEntryRole.command => null, + BusyMaxMenuEntryRole.radio => ExcludeSemantics( + child: IgnorePointer( + child: YaruRadio( + value: index, + groupValue: selectedRadioIndex, + onChanged: (_) {}, + hasFocusBorder: false, + ), + ), + ), + BusyMaxMenuEntryRole.toggle => ExcludeSemantics( + child: IgnorePointer( + child: YaruCheckbox( + value: entries[index].selected, + onChanged: (_) {}, + hasFocusBorder: false, + ), + ), + ), + }, focusKey: index == firstEnabledIndex ? firstEnabledKey : null, routeKey: index == 0 ? routeKey : null, ), @@ -2678,12 +2716,17 @@ Widget _busyMaxFocusableFallbackEntry( entry, selectionIndicator: selectionIndicator, ); - if (selectionIndicator != null) { - child = Semantics( - selected: entry.selected, - inMutuallyExclusiveGroup: true, - child: child, - ); + switch (entry.role) { + case BusyMaxMenuEntryRole.command: + break; + case BusyMaxMenuEntryRole.radio: + child = Semantics( + selected: entry.selected, + inMutuallyExclusiveGroup: true, + child: child, + ); + case BusyMaxMenuEntryRole.toggle: + child = Semantics(checked: entry.selected, child: child); } if (focusKey != null) { child = KeyedSubtree(key: focusKey, child: child); diff --git a/lib/src/app/busymax_glyphs.dart b/lib/src/app/busymax_glyphs.dart index 912ba00b..da23c5c0 100644 --- a/lib/src/app/busymax_glyphs.dart +++ b/lib/src/app/busymax_glyphs.dart @@ -29,6 +29,15 @@ abstract final class BusyMaxGlyphs { if (icon == Icons.edit_outlined) { return 'document-edit-symbolic'; } + if (icon == Icons.palette_outlined) { + return 'color-select-symbolic'; + } + if (icon == Icons.notifications_outlined) { + return 'preferences-system-notifications-symbolic'; + } + if (icon == Icons.notifications_off_outlined) { + return 'notifications-disabled-symbolic'; + } if (icon == Icons.calendar_view_day_outlined || icon == YaruIcons.calendar_day) { return 'calendar-app-symbolic'; diff --git a/lib/src/app/common/busymax_design_values.dart b/lib/src/app/common/busymax_design_values.dart new file mode 100644 index 00000000..3281ff20 --- /dev/null +++ b/lib/src/app/common/busymax_design_values.dart @@ -0,0 +1,72 @@ +import 'package:flutter/widgets.dart'; + +abstract final class BusyMaxSpacing { + static const double xSmall = 4; + static const double small = 8; + static const double medium = 12; + static const double large = 16; + static const double xLarge = 24; + static const double xxLarge = 32; +} + +abstract final class BusyMaxDimensions { + static const double minimumWindowWidth = 900; + static const double minimumWindowHeight = 600; + static const double initialWindowWidth = 1280; + static const double initialWindowHeight = 800; + static const double navigationCompactWidth = 48; + static const double navigationOpenWidth = 240; + static const double sourcePaneWidth = 264; + static const double editorWidth = 440; + static const double contentMaximumWidth = 1120; +} + +abstract final class BusyMaxBreakpoints { + static const double compact = 720; + static const double sourcePane = 980; + static const double wide = 1280; + + static bool showsSourcePane(double width) => width >= sourcePane; +} + +abstract final class BusyMaxMotion { + static const Duration fast = Duration(milliseconds: 100); + static const Duration normal = Duration(milliseconds: 167); + static const Duration slow = Duration(milliseconds: 250); +} + +enum BusyMaxSemanticColorRole { + accent, + canvas, + surface, + foreground, + secondaryForeground, + border, + success, + warning, + error, + information, +} + +enum BusyMaxInteractionState { + normal, + hovered, + pressed, + focused, + selected, + disabled, +} + +@immutable +final class BusyMaxPaneState { + const BusyMaxPaneState({ + required this.availableWidth, + required this.sourcePaneRequested, + }); + + final double availableWidth; + final bool sourcePaneRequested; + + bool get showsSourcePane => + sourcePaneRequested && BusyMaxBreakpoints.showsSourcePane(availableWidth); +} diff --git a/lib/src/app/windows/windows_busymax_app.dart b/lib/src/app/windows/windows_busymax_app.dart new file mode 100644 index 00000000..01e83597 --- /dev/null +++ b/lib/src/app/windows/windows_busymax_app.dart @@ -0,0 +1,260 @@ +import 'dart:async'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../features/auth/data/auth_repository.dart'; +import '../../l10n/locale_resolution.dart'; +import '../../platform/common/desktop_services.dart'; +import '../../ui/windows/windows_schedule_page.dart'; +import '../../ui/windows/windows_settings_page.dart'; +import '../../ui/windows/windows_sign_in_page.dart'; +import '../../ui/windows/windows_tasks_page.dart'; +import '../../ui/windows/windows_workspace_shell.dart'; +import '../../ui/windows/windows_desktop_runtime.dart'; +import '../../ui/windows/windows_calendar_activation_flows.dart'; +import '../app_bootstrap.dart'; + +final windowsRootNavigatorKey = GlobalKey(); + +final windowsAppRouter = GoRouter( + navigatorKey: windowsRootNavigatorKey, + initialLocation: '/', + routes: [ + GoRoute(path: '/', builder: (_, _) => const _WindowsHomeGate()), + GoRoute( + path: '/sign-in', + builder: (_, state) => WindowsSignInPage( + addingAccount: state.uri.queryParameters['add'] == 'true', + ), + ), + GoRoute( + path: '/schedule', + builder: (_, _) => const WindowsWorkspaceShell( + destination: WindowsWorkspaceDestination.schedule, + child: WindowsSchedulePage(), + ), + ), + GoRoute( + path: '/tasks', + builder: (_, _) => const WindowsWorkspaceShell( + destination: WindowsWorkspaceDestination.tasks, + child: WindowsTasksPage(), + ), + ), + GoRoute( + path: '/settings', + builder: (_, _) => const WindowsWorkspaceShell( + destination: WindowsWorkspaceDestination.settings, + child: WindowsSettingsPage(), + ), + ), + ], +); + +class WindowsBusyMaxApp extends ConsumerStatefulWidget { + const WindowsBusyMaxApp({super.key, this.startMinimizedAtLaunch = false}); + + final bool startMinimizedAtLaunch; + + @override + ConsumerState createState() => _WindowsBusyMaxAppState(); +} + +class _WindowsBusyMaxAppState extends ConsumerState + with WidgetsBindingObserver { + StreamSubscription? _activationSubscription; + StreamSubscription? _navigationSubscription; + StreamSubscription? _appearanceSubscription; + int _platformChangeSequence = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _activationSubscription = ref + .read(desktopActivationServiceProvider) + .activations + .listen(_handleActivation); + _navigationSubscription = ref + .read(desktopNavigationServiceProvider) + .requests + .listen(_handleNavigation); + _appearanceSubscription = ref + .read(systemAppearanceSourceProvider) + .changes + .listen((_) => _platformChanged()); + // Starts in-process reminder scheduling. Explicit Quit disposes it before + // asking the runner to terminate. + ref.read(notificationSchedulerProvider); + } + + @override + void didChangeAccessibilityFeatures() => _platformChanged(); + + @override + void didChangePlatformBrightness() => _platformChanged(); + + void _platformChanged() { + if (mounted) setState(() => _platformChangeSequence++); + } + + Future _handleActivation(DesktopActivation activation) async { + if (activation.requiresVisibleWindow) { + await ref.read(desktopWindowServiceProvider).showWindow(); + } + switch (activation.kind) { + case DesktopActivationKind.normalLaunch: + windowsAppRouter.go('/'); + case DesktopActivationKind.startMinimized: + return; + case DesktopActivationKind.icsFile: + windowsAppRouter.go('/schedule'); + await WidgetsBinding.instance.endOfFrame; + final context = windowsRootNavigatorKey.currentContext; + if (context != null && context.mounted) { + await showWindowsIcsImportFlow( + context, + ref, + filePath: activation.value, + ); + } + case DesktopActivationKind.webCal: + windowsAppRouter.go('/settings'); + await WidgetsBinding.instance.endOfFrame; + final context = windowsRootNavigatorKey.currentContext; + if (context != null && context.mounted) { + await showWindowsWebCalSubscriptionFlow( + context, + ref, + initialUrl: activation.value, + ); + } + case DesktopActivationKind.notification: + final scheduleId = activation.payload?['notificationScheduleId']; + if (scheduleId == null) return; + await ref + .read(notificationSchedulerProvider) + .handleActivation( + notificationScheduleId: scheduleId, + action: activation.action!, + ); + if (activation.action == 'default' || activation.action == 'open') { + windowsAppRouter.go('/schedule'); + } + } + } + + void _handleNavigation(DesktopNavigationRequest request) { + windowsAppRouter.go(switch (request.destination) { + DesktopNavigationDestination.schedule => '/schedule', + DesktopNavigationDestination.tasks => '/tasks', + DesktopNavigationDestination.settings => '/settings', + DesktopNavigationDestination.signIn => '/sign-in', + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + unawaited(_activationSubscription?.cancel()); + unawaited(_navigationSubscription?.cancel()); + unawaited(_appearanceSubscription?.cancel()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final settings = ref.watch(appSettingsControllerProvider); + final dispatcher = WidgetsBinding.instance.platformDispatcher; + final appearance = ref.watch(systemAppearanceSourceProvider); + final highContrast = appearance.highContrast; + final reducedMotion = dispatcher.accessibilityFeatures.disableAnimations; + final accentColor = appearance.accentColor?.toAccentColor(); + FluentThemeData theme(Brightness brightness) { + final background = brightness == Brightness.dark + ? Colors.black + : Colors.white; + final foreground = brightness == Brightness.dark + ? Colors.white + : Colors.black; + return FluentThemeData( + brightness: brightness, + accentColor: highContrast ? foreground.toAccentColor() : accentColor, + activeColor: highContrast ? background : null, + inactiveColor: highContrast ? foreground : null, + typography: _windowsTypography(brightness), + scaffoldBackgroundColor: highContrast ? background : null, + cardColor: highContrast ? background : null, + menuColor: highContrast ? background : null, + shadowColor: highContrast ? foreground : null, + fasterAnimationDuration: reducedMotion ? Duration.zero : null, + fastAnimationDuration: reducedMotion ? Duration.zero : null, + mediumAnimationDuration: reducedMotion ? Duration.zero : null, + slowAnimationDuration: reducedMotion ? Duration.zero : null, + ); + } + + return FluentApp.router( + key: ValueKey(( + _platformChangeSequence, + appearance.brightness, + highContrast, + reducedMotion, + )), + title: 'BusyMax', + debugShowCheckedModeBanner: false, + routerConfig: windowsAppRouter, + theme: theme(Brightness.light), + darkTheme: theme(Brightness.dark), + themeMode: settings.themeMode, + locale: settings.locale, + // FluentApp appends Fluent and framework delegates. BusyMax adds only + // its generated delegate here; Ubuntu delegates remain Linux-only. + localizationsDelegates: const [AppLocalizations.delegate], + localeListResolutionCallback: resolveBusyMaxLocales, + supportedLocales: busyMaxSupportedLocales, + builder: (context, child) => WindowsDesktopRuntime( + startMinimizedAtLaunch: widget.startMinimizedAtLaunch, + child: child ?? const SizedBox.shrink(), + ), + ); + } +} + +Typography _windowsTypography(Brightness brightness) { + final defaults = Typography.fromBrightness(brightness: brightness); + TextStyle? windowsFont(TextStyle? style) => style?.copyWith( + fontFamily: 'Segoe UI Variable', + fontFamilyFallback: const ['Segoe UI'], + ); + return Typography.raw( + display: windowsFont(defaults.display), + titleLarge: windowsFont(defaults.titleLarge), + title: windowsFont(defaults.title), + subtitle: windowsFont(defaults.subtitle), + bodyLarge: windowsFont(defaults.bodyLarge), + bodyStrong: windowsFont(defaults.bodyStrong), + body: windowsFont(defaults.body), + caption: windowsFont(defaults.caption), + ); +} + +class _WindowsHomeGate extends ConsumerWidget { + const _WindowsHomeGate(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(authSessionControllerProvider); + return switch (session.status) { + AuthSessionStatus.loading => const Center(child: ProgressRing()), + AuthSessionStatus.signedIn => const WindowsWorkspaceShell( + destination: WindowsWorkspaceDestination.schedule, + child: WindowsSchedulePage(), + ), + _ => const WindowsSignInPage(), + }; + } +} diff --git a/lib/src/calendar_providers/calendar_colors.dart b/lib/src/calendar_providers/calendar_colors.dart index 21af0b23..2c3c6479 100644 --- a/lib/src/calendar_providers/calendar_colors.dart +++ b/lib/src/calendar_providers/calendar_colors.dart @@ -1,5 +1,67 @@ +import 'dart:math' as math; + import 'package:busymax/src/providers/busy_provider.dart'; +class CalendarColorChoice { + const CalendarColorChoice({ + required this.providerValue, + required this.backgroundColor, + }); + + final String providerValue; + final String backgroundColor; +} + +const googleCalendarColorChoices = [ + CalendarColorChoice(providerValue: '#3584e4', backgroundColor: '#3584e4'), + CalendarColorChoice(providerValue: '#1c71d8', backgroundColor: '#1c71d8'), + CalendarColorChoice(providerValue: '#33d17a', backgroundColor: '#33d17a'), + CalendarColorChoice(providerValue: '#26a269', backgroundColor: '#26a269'), + CalendarColorChoice(providerValue: '#2ec1c9', backgroundColor: '#2ec1c9'), + CalendarColorChoice(providerValue: '#f6d32d', backgroundColor: '#f6d32d'), + CalendarColorChoice(providerValue: '#ff7800', backgroundColor: '#ff7800'), + CalendarColorChoice(providerValue: '#e01b24', backgroundColor: '#e01b24'), + CalendarColorChoice(providerValue: '#c061cb', backgroundColor: '#c061cb'), + CalendarColorChoice(providerValue: '#9141ac', backgroundColor: '#9141ac'), + CalendarColorChoice(providerValue: '#986a44', backgroundColor: '#986a44'), + CalendarColorChoice(providerValue: '#77767b', backgroundColor: '#77767b'), +]; + +const microsoftCalendarColorChoices = [ + CalendarColorChoice(providerValue: 'lightBlue', backgroundColor: '#0078D4'), + CalendarColorChoice(providerValue: 'lightGreen', backgroundColor: '#107C10'), + CalendarColorChoice(providerValue: 'lightOrange', backgroundColor: '#D83B01'), + CalendarColorChoice(providerValue: 'lightGray', backgroundColor: '#7A7574'), + CalendarColorChoice(providerValue: 'lightYellow', backgroundColor: '#F2C811'), + CalendarColorChoice(providerValue: 'lightTeal', backgroundColor: '#008575'), + CalendarColorChoice(providerValue: 'lightPink', backgroundColor: '#E3008C'), + CalendarColorChoice(providerValue: 'lightBrown', backgroundColor: '#8E562E'), + CalendarColorChoice(providerValue: 'lightRed', backgroundColor: '#D13438'), +]; + +List calendarColorChoices(BusyProvider provider) { + return switch (provider) { + BusyProvider.google => googleCalendarColorChoices, + BusyProvider.microsoft => microsoftCalendarColorChoices, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => const [], + }; +} + +String calendarColorForegroundHex(String backgroundColor) { + final normalized = backgroundColor.replaceFirst('#', ''); + if (normalized.length != 6) { + throw FormatException('Expected a six-digit RGB calendar color.'); + } + final value = int.parse(normalized, radix: 16); + final red = _linearColorChannel((value >> 16) & 0xff); + final green = _linearColorChannel((value >> 8) & 0xff); + final blue = _linearColorChannel(value & 0xff); + final luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; + return luminance > 0.179 ? '#000000' : '#ffffff'; +} + String? calendarSourceBackgroundColorHex({ required BusyProvider provider, String? backgroundColor, @@ -16,21 +78,23 @@ String? calendarSourceBackgroundColorHex({ } String? microsoftCalendarColorHex(String? value) { - return switch (value?.trim()) { - 'lightBlue' => '#0078D4', - 'lightGreen' => '#107C10', - 'lightOrange' => '#D83B01', - 'lightGray' => '#7A7574', - 'lightYellow' => '#F2C811', - 'lightTeal' => '#008575', - 'lightPink' => '#E3008C', - 'lightBrown' => '#8E562E', - 'lightRed' => '#D13438', - _ => null, - }; + final normalized = value?.trim(); + for (final choice in microsoftCalendarColorChoices) { + if (choice.providerValue == normalized) { + return choice.backgroundColor; + } + } + return null; } String? _nonEmpty(String? value) { final trimmed = value?.trim(); return trimmed == null || trimmed.isEmpty ? null : trimmed; } + +double _linearColorChannel(int value) { + final channel = value / 255; + return channel <= 0.04045 + ? channel / 12.92 + : math.pow((channel + 0.055) / 1.055, 2.4).toDouble(); +} diff --git a/lib/src/calendar_providers/calendar_create_identity.dart b/lib/src/calendar_providers/calendar_create_identity.dart new file mode 100644 index 00000000..a937e6ea --- /dev/null +++ b/lib/src/calendar_providers/calendar_create_identity.dart @@ -0,0 +1,18 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +const calendarEventGoogleCreateIdKey = '_googleCreateEventId'; +const calendarEventMicrosoftTransactionIdKey = '_microsoftTransactionId'; + +/// Google event IDs accept lowercase base32hex characters. A SHA-256 digest +/// uses only the permitted 0-9 and a-f subset and remains stable across retries. +String googleCalendarCreateEventId(String operationId) { + return sha256 + .convert(utf8.encode('busymax:event-create:$operationId')) + .toString(); +} + +/// Pending cloud operations use UUIDs, which are suitable Graph transaction +/// identifiers and are already persisted before the request is sent. +String microsoftCalendarCreateTransactionId(String operationId) => operationId; diff --git a/lib/src/calendar_providers/calendar_mutation.dart b/lib/src/calendar_providers/calendar_mutation.dart index 3e033c6b..c8d0d902 100644 --- a/lib/src/calendar_providers/calendar_mutation.dart +++ b/lib/src/calendar_providers/calendar_mutation.dart @@ -16,10 +16,26 @@ class CalendarMutation { final String? colorId; } +const calendarMutationScopeKey = '_calendarMutationScope'; +const calendarMutationScopeGlobal = 'global'; +const calendarMutationScopePersonal = 'personal'; +const calendarRemovalPreviousHiddenKey = '_previousHidden'; +const calendarPatchPreviousValuesKey = '_previousValues'; + const calendarEventClearFieldsKey = '_clearFields'; const calendarEventRecurrenceField = 'recurrenceJson'; const calendarEventAttendeesField = 'attendeesJson'; const calendarEventGuestUpdatePolicyKey = '_guestUpdatePolicy'; +const calendarEventRecurringScopeKey = '_recurringScope'; +const calendarEventTargetProviderIdKey = '_targetProviderEventId'; +const calendarEventOriginalStartKey = '_originalStart'; +const calendarEventOriginalEndKey = '_originalEnd'; +const calendarEventDestinationCalendarIdKey = '_destinationCalendarId'; +const calendarEventDestinationSourceIdKey = '_destinationSourceId'; +const calendarEventCopyConfirmationRequiredKey = '_copyConfirmationRequired'; +const calendarEventCopyConfirmedKey = '_copyConfirmed'; +const calendarEventCopyDestinationEventIdKey = '_copyDestinationEventId'; +const calendarEventSemanticBaselineKey = '__busymaxSemanticBaseline'; enum CalendarGuestUpdatePolicy { send, doNotSend } @@ -54,6 +70,9 @@ class CalendarEventMutation { this.responseRequested, this.hideAttendees, this.allowNewTimeProposals, + this.providerEventId, + this.transactionId, + this.providerRaw, }); final String? title; @@ -83,4 +102,7 @@ class CalendarEventMutation { final bool? responseRequested; final bool? hideAttendees; final bool? allowNewTimeProposals; + final String? providerEventId; + final String? transactionId; + final Map? providerRaw; } diff --git a/lib/src/calendar_providers/calendar_provider_capabilities.dart b/lib/src/calendar_providers/calendar_provider_capabilities.dart index 3e698cdc..804aaed2 100644 --- a/lib/src/calendar_providers/calendar_provider_capabilities.dart +++ b/lib/src/calendar_providers/calendar_provider_capabilities.dart @@ -1,3 +1,5 @@ +import '../providers/busy_provider.dart'; + class CalendarProviderCapabilities { const CalendarProviderCapabilities({ required this.supportsCreateCalendar, @@ -32,6 +34,20 @@ class CalendarProviderCapabilities { final bool supportsEventDelta; } +class CalendarManagementCapabilities { + const CalendarManagementCapabilities({ + required this.supportsCreate, + required this.supportsRename, + required this.supportsDelete, + required this.supportsColor, + }); + + final bool supportsCreate; + final bool supportsRename; + final bool supportsDelete; + final bool supportsColor; +} + const googleCalendarProviderCapabilities = CalendarProviderCapabilities( supportsCreateCalendar: true, supportsDeleteCalendar: true, @@ -65,3 +81,21 @@ const microsoftCalendarProviderCapabilities = CalendarProviderCapabilities( supportsCalendarDelta: false, supportsEventDelta: true, ); + +CalendarManagementCapabilities calendarManagementCapabilities( + BusyProvider provider, +) { + final cloudCapabilities = switch (provider) { + BusyProvider.google => googleCalendarProviderCapabilities, + BusyProvider.microsoft => microsoftCalendarProviderCapabilities, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, + }; + return CalendarManagementCapabilities( + supportsCreate: cloudCapabilities?.supportsCreateCalendar ?? false, + supportsRename: cloudCapabilities != null, + supportsDelete: cloudCapabilities?.supportsDeleteCalendar ?? false, + supportsColor: cloudCapabilities?.supportsCalendarColor ?? false, + ); +} diff --git a/lib/src/calendar_providers/calendar_sync_dto.dart b/lib/src/calendar_providers/calendar_sync_dto.dart index 911ed3cf..f442c855 100644 --- a/lib/src/calendar_providers/calendar_sync_dto.dart +++ b/lib/src/calendar_providers/calendar_sync_dto.dart @@ -15,6 +15,8 @@ class CalendarSourceDto { this.colorId, this.timeZone, this.accessRole, + this.dataOwner, + this.isRemovable, this.isDeleted = false, this.rawJson = const {}, }); @@ -32,6 +34,8 @@ class CalendarSourceDto { final String? colorId; final String? timeZone; final String? accessRole; + final String? dataOwner; + final bool? isRemovable; final bool isDeleted; final Map rawJson; } diff --git a/lib/src/calendar_providers/cloud_calendar_client.dart b/lib/src/calendar_providers/cloud_calendar_client.dart index 6bfdd945..d6974ea6 100644 --- a/lib/src/calendar_providers/cloud_calendar_client.dart +++ b/lib/src/calendar_providers/cloud_calendar_client.dart @@ -49,6 +49,14 @@ abstract interface class CloudCalendarClient { CalendarGuestUpdatePolicy.send, }); + Future moveEvent({ + required String sourceCalendarId, + required String eventId, + required String destinationCalendarId, + CalendarGuestUpdatePolicy guestUpdatePolicy = + CalendarGuestUpdatePolicy.send, + }); + Future respondToEvent({ required String calendarId, required String eventId, @@ -78,3 +86,14 @@ abstract interface class CloudCalendarClient { required DateTime rangeEnd, }); } + +/// User-specific calendar-list operations supported by providers that expose +/// calendar metadata separately from the signed-in user's personalization. +abstract interface class CalendarListManagementClient { + Future updateCalendarListEntry( + String calendarId, + CalendarMutation mutation, + ); + + Future deleteCalendarListEntry(String calendarId); +} diff --git a/lib/src/config/build_config.dart b/lib/src/config/build_config.dart index df0974de..616470e0 100644 --- a/lib/src/config/build_config.dart +++ b/lib/src/config/build_config.dart @@ -11,6 +11,12 @@ class BuildConfig { this.microsoftGraphBaseUrl = 'https://graph.microsoft.com/v1.0', this.googleApiBaseUrl = 'https://www.googleapis.com', this.feedbackEndpoint = 'https://busystack.org/api/feedback', + this.privacyPolicyUrl = '', + this.supportUrl = '', + this.homepageUrl = 'https://busystack.org', + this.windowsAppUserModelId = '', + this.windowsPackageVersion = '', + this.windowsStoreMode = false, String? apiBaseUrl, required this.oauthAuthorizationEndpoint, required this.oauthTokenEndpoint, @@ -51,6 +57,23 @@ class BuildConfig { 'BUSYSTACK_FEEDBACK_ENDPOINT', defaultValue: 'https://busystack.org/api/feedback', ), + privacyPolicyUrl: const String.fromEnvironment( + 'BUSYMAX_PRIVACY_POLICY_URL', + ), + supportUrl: const String.fromEnvironment('BUSYMAX_SUPPORT_URL'), + homepageUrl: const String.fromEnvironment( + 'BUSYMAX_HOMEPAGE_URL', + defaultValue: 'https://busystack.org', + ), + windowsAppUserModelId: const String.fromEnvironment( + 'BUSYMAX_WINDOWS_AUMID', + ), + windowsPackageVersion: const String.fromEnvironment( + 'BUSYMAX_MSIX_VERSION', + ), + windowsStoreMode: const bool.fromEnvironment( + 'BUSYMAX_WINDOWS_STORE_MODE', + ), oauthAuthorizationEndpoint: const String.fromEnvironment( 'GOOGLE_OAUTH_AUTHORIZATION_ENDPOINT', defaultValue: 'https://accounts.google.com/o/oauth2/v2/auth', @@ -82,6 +105,12 @@ class BuildConfig { final String microsoftGraphBaseUrl; final String googleApiBaseUrl; final String feedbackEndpoint; + final String privacyPolicyUrl; + final String supportUrl; + final String homepageUrl; + final String windowsAppUserModelId; + final String windowsPackageVersion; + final bool windowsStoreMode; final String apiBaseUrl; final String oauthAuthorizationEndpoint; final String oauthTokenEndpoint; @@ -107,8 +136,8 @@ class BuildConfig { 'Please install an official build.'; } - return 'Missing provider configuration. Run with at least one provider:\n' - 'flutter run -d linux ' + return 'Missing provider configuration. Run the platform entrypoint with ' + 'at least one provider:\n' '--dart-define=GOOGLE_OAUTH_CLIENT_ID= ' '--dart-define=GOOGLE_OAUTH_CLIENT_SECRET= ' '--dart-define=MICROSOFT_OAUTH_CLIENT_ID='; diff --git a/lib/src/core/secrets/secret_store.dart b/lib/src/core/secrets/secret_store.dart index 0e9d083c..c0797c62 100644 --- a/lib/src/core/secrets/secret_store.dart +++ b/lib/src/core/secrets/secret_store.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -12,16 +11,46 @@ import '../logging/redacting_logger.dart'; const secretRecordSchemaVersion = 1; -enum CredentialKind { oauth, appleAppSpecificPassword, nextcloudAppPassword } +enum CredentialKind { + oauth, + appleAppSpecificPassword, + nextcloudAppPassword, + webCalSubscription, +} extension CredentialKindValue on CredentialKind { String get storageValue => switch (this) { CredentialKind.oauth => 'oauth', CredentialKind.appleAppSpecificPassword => 'apple_app_specific_password', CredentialKind.nextcloudAppPassword => 'nextcloud_app_password', + CredentialKind.webCalSubscription => 'webcal_subscription', }; } +CredentialKind credentialKindForProvider(BusyProvider provider) => + switch (provider) { + BusyProvider.google || BusyProvider.microsoft => CredentialKind.oauth, + BusyProvider.appleICloud => CredentialKind.appleAppSpecificPassword, + BusyProvider.nextcloud => CredentialKind.nextcloudAppPassword, + BusyProvider.webCal => CredentialKind.webCalSubscription, + }; + +bool credentialKindMatchesProvider( + BusyProvider provider, + CredentialKind kind, +) => credentialKindForProvider(provider) == kind; + +CredentialKind _credentialKindFromSecretStorage(Object? value) => + switch (value) { + 'oauth' => CredentialKind.oauth, + 'apple_app_specific_password' => CredentialKind.appleAppSpecificPassword, + 'nextcloud_app_password' => CredentialKind.nextcloudAppPassword, + 'webcal_subscription' => CredentialKind.webCalSubscription, + _ => throw SecretStoreCorruptException( + 'Unsupported credential kind $value.', + ), + }; + sealed class SecretRecord { const SecretRecord({required this.provider, required this.kind}); @@ -39,8 +68,14 @@ sealed class SecretRecord { final provider = BusyProviderCodec.requireStorageValue( json['provider']?.toString(), ); - return switch (json['kind']) { - 'oauth' => OAuthSecretRecord( + final kind = _credentialKindFromSecretStorage(json['kind']); + if (!credentialKindMatchesProvider(provider, kind)) { + throw const SecretStoreCorruptException( + 'The credential provider and kind do not match.', + ); + } + return switch (kind) { + CredentialKind.oauth => OAuthSecretRecord( provider: provider, tokenSet: OAuthTokenSet( accessToken: _requiredSecretString(json, 'accessToken'), @@ -53,19 +88,29 @@ sealed class SecretRecord { scopes: _stringList(json['scopes']).toSet(), ), ), - 'apple_app_specific_password' => AppleICloudSecretRecord( + CredentialKind.appleAppSpecificPassword => AppleICloudSecretRecord( username: _requiredSecretString(json, 'username'), appSpecificPassword: _requiredSecretString(json, 'appSpecificPassword'), ), - 'nextcloud_app_password' => NextcloudSecretRecord( + CredentialKind.nextcloudAppPassword => NextcloudSecretRecord( canonicalServer: Uri.parse( _requiredSecretString(json, 'canonicalServer'), ), loginName: _requiredSecretString(json, 'loginName'), appPassword: _requiredSecretString(json, 'appPassword'), ), - final Object? value => throw SecretStoreCorruptException( - 'Unsupported credential kind $value.', + CredentialKind.webCalSubscription => WebCalSecretRecord( + normalizedSubscriptionUri: _requiredSecretString( + json, + 'normalizedSubscriptionUri', + ), + validatorTargetUri: switch (_optionalSecretString( + json, + 'validatorTargetUri', + )) { + final value? => Uri.parse(value), + null => null, + }, ), }; } @@ -76,11 +121,14 @@ sealed class SecretRecord { } final class OAuthSecretRecord extends SecretRecord { - const OAuthSecretRecord({required super.provider, required this.tokenSet}) - : assert( - provider == BusyProvider.google || provider == BusyProvider.microsoft, - ), - super(kind: CredentialKind.oauth); + OAuthSecretRecord({required super.provider, required this.tokenSet}) + : super(kind: CredentialKind.oauth) { + if (!credentialKindMatchesProvider(provider, kind)) { + throw const SecretStoreCorruptException( + 'OAuth credentials require a Google or Microsoft provider.', + ); + } + } final OAuthTokenSet tokenSet; @@ -168,6 +216,49 @@ final class NextcloudSecretRecord extends SecretRecord { }; } +final class WebCalSecretRecord extends SecretRecord { + WebCalSecretRecord({ + required this.normalizedSubscriptionUri, + this.validatorTargetUri, + }) : super( + provider: BusyProvider.webCal, + kind: CredentialKind.webCalSubscription, + ) { + _requireSafeSubscriptionUri(Uri.parse(normalizedSubscriptionUri)); + final target = validatorTargetUri; + if (target != null) _requireSafeSubscriptionUri(target); + } + + final String normalizedSubscriptionUri; + final Uri? validatorTargetUri; + + @override + Map toJson() => { + 'schemaVersion': secretRecordSchemaVersion, + 'kind': kind.storageValue, + 'provider': provider.storageValue, + 'normalizedSubscriptionUri': normalizedSubscriptionUri, + if (validatorTargetUri != null) + 'validatorTargetUri': validatorTargetUri.toString(), + }; + + WebCalSecretRecord copyWithValidatorTarget(Uri? value) => WebCalSecretRecord( + normalizedSubscriptionUri: normalizedSubscriptionUri, + validatorTargetUri: value, + ); +} + +void _requireSafeSubscriptionUri(Uri value) { + if (value.scheme != 'https' || + value.host.isEmpty || + value.userInfo.isNotEmpty || + value.hasFragment) { + throw const SecretStoreCorruptException( + 'A WebCal credential contains an invalid subscription URI.', + ); + } +} + abstract interface class SecretStore { Future readActiveAccountId(); Future setActiveAccountId(String accountId); @@ -219,12 +310,36 @@ extension OAuthSecretStoreAccess on SecretStore { } } +class SecretStoragePresentation { + const SecretStoragePresentation({ + required this.backendDomain, + required this.runtimeBackend, + required this.unavailableMessage, + }); + + static const generic = SecretStoragePresentation( + backendDomain: 'busymax.secure_storage', + runtimeBackend: 'platform-credential-storage', + unavailableMessage: + 'Secure credential storage is temporarily unavailable. Check your ' + 'operating system credential storage and try again.', + ); + + final String backendDomain; + final String runtimeBackend; + final String unavailableMessage; +} + class SecureSecretStore implements SecretStore { - SecureSecretStore(this._storage, {RedactingLogger? logger}) - : _logger = logger ?? RedactingLogger(Logger('SecureSecretStore')); + SecureSecretStore( + this._storage, { + RedactingLogger? logger, + this.presentation = SecretStoragePresentation.generic, + }) : _logger = logger ?? RedactingLogger(Logger('SecureSecretStore')); final FlutterSecureStorage _storage; final RedactingLogger _logger; + final SecretStoragePresentation presentation; var _loggedRuntime = false; static const activeAccountKey = 'busymax.secret.active_account_id'; @@ -361,8 +476,7 @@ class SecureSecretStore implements SecretStore { } _loggedRuntime = true; _logger.info( - 'Secret storage runtime: backend=flutter-secure-storage ' - 'snap=${_isRunningInSnap()} secret_backend=${_secretBackendLabel()}', + 'Secret storage runtime: backend=${presentation.runtimeBackend}', ); } @@ -372,11 +486,11 @@ class SecureSecretStore implements SecretStore { ) { _logger.warning( 'Secret storage $operation failed: ' - '${sanitizedFlutterSecureStorageError(error)}', + '${sanitizedFlutterSecureStorageError(error, backendDomain: presentation.backendDomain)}', ); - return const SecretStoreException( + return SecretStoreException( 'SecretStoreUnavailable', - secretStorageUnavailableMessage, + presentation.unavailableMessage, ); } } @@ -450,12 +564,26 @@ class SecretStoreCredentialMismatchException extends SecretStoreException { } const secretStorageUnavailableMessage = - 'Secure credential storage is locked. Unlock your system keyring and try again.'; + 'Secure credential storage is temporarily unavailable. Check your operating system credential storage and try again.'; + +String sanitizedFlutterSecureStorageError( + PlatformException error, { + String backendDomain = 'busymax.secure_storage', +}) { + String safeIdentifier(String value, String fallback) { + final normalized = value.trim(); + return normalized.length <= 96 && + RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(normalized) + ? normalized + : fallback; + } -String sanitizedFlutterSecureStorageError(PlatformException error) { - final message = redactForLog(error.message).replaceAll(RegExp(r'\s+'), ' '); - return 'domain=flutter_secure_storage_linux code=${error.code} ' - 'message=${message.trim()} details_type=${error.details.runtimeType}'; + // Platform messages and details are intentionally omitted. Backends may + // include key names, paths, or credential-provider output in those fields. + final domain = safeIdentifier(backendDomain, 'busymax.secure_storage'); + final code = safeIdentifier(error.code, 'backend-error'); + return 'domain=$domain code=$code ' + 'details_type=${error.details.runtimeType}'; } SecretRecord _decodeCredential(String serialized) { @@ -504,13 +632,3 @@ const _legacyOAuthFieldNames = [ 'token_type', 'scope', ]; - -bool _isRunningInSnap() => Platform.environment['SNAP']?.isNotEmpty ?? false; - -String _secretBackendLabel() { - final backend = Platform.environment['SECRET_BACKEND']; - if (backend == null || backend.isEmpty) { - return ''; - } - return backend == 'file' ? 'file' : ''; -} diff --git a/lib/src/core/time/linux_gweather_location_source.dart b/lib/src/core/time/linux_gweather_location_source.dart index 9beb8788..d0949f8a 100644 --- a/lib/src/core/time/linux_gweather_location_source.dart +++ b/lib/src/core/time/linux_gweather_location_source.dart @@ -3,27 +3,9 @@ import 'dart:ffi'; import 'dart:io'; import 'dart:isolate'; -import 'package:flutter/foundation.dart'; - -@immutable -class BusyMaxSystemTimeZoneLocation { - const BusyMaxSystemTimeZoneLocation({ - required this.name, - required this.englishName, - required this.countryCode, - required this.timeZoneId, - }); - - final String name; - final String englishName; - final String? countryCode; - final String timeZoneId; +import 'system_time_zone_location.dart'; - bool matches(String normalizedQuery) { - return name.toLowerCase().contains(normalizedQuery) || - englishName.toLowerCase().contains(normalizedQuery); - } -} +export 'system_time_zone_location.dart'; Future> loadLinuxSystemTimeZoneLocations() async { diff --git a/lib/src/core/time/local_time_zone.dart b/lib/src/core/time/local_time_zone.dart index 59453c8a..31b6bb16 100644 --- a/lib/src/core/time/local_time_zone.dart +++ b/lib/src/core/time/local_time_zone.dart @@ -1,5 +1,17 @@ import 'dart:io'; +import '../../platform/common/desktop_services.dart'; + +final class LinuxLocalTimeZoneSource implements LocalTimeZoneSource { + const LinuxLocalTimeZoneSource(); + + @override + String? get diagnostic => null; + + @override + Future currentIanaTimeZone() async => localIanaTimeZone(); +} + String localIanaTimeZone() { try { final timezoneFile = File('/etc/timezone'); diff --git a/lib/src/core/time/provider_date_time.dart b/lib/src/core/time/provider_date_time.dart index a89570ec..a4093f89 100644 --- a/lib/src/core/time/provider_date_time.dart +++ b/lib/src/core/time/provider_date_time.dart @@ -59,6 +59,43 @@ DateTime? providerDateTimeAsUtcInstant(String? value, String? timeZone) { return parsed.toUtc(); } +/// Parses a provider timestamp as the wall-clock value shown by an editor. +/// +/// Explicit offsets already carry their wall time. UTC instants are converted +/// to [timeZone] when it is an IANA zone. Zone-less provider values are wall +/// times already and must not be shifted by the machine's local zone. +DateTime? providerDateTimeAsWallTime(String? value, String? timeZone) { + if (value == null || value.isEmpty) return null; + if (!value.contains('T')) { + return DateTime.tryParse( + value.length >= 10 ? value.substring(0, 10) : value, + ); + } + if (RegExp(r'[+-]\d{2}:?\d{2}$').hasMatch(value)) { + return DateTime.tryParse( + value.replaceFirst(RegExp(r'[+-]\d{2}:?\d{2}$'), ''), + ); + } + final parsed = DateTime.tryParse(value); + if (parsed == null) return null; + if (parsed.isUtc) { + return providerUtcInstantAsWallTime(parsed, timeZone); + } + if (isUtcTimeZone(timeZone)) { + return DateTime.utc( + parsed.year, + parsed.month, + parsed.day, + parsed.hour, + parsed.minute, + parsed.second, + parsed.millisecond, + parsed.microsecond, + ); + } + return parsed; +} + bool providerDateTimeIsInstant(String? value, String? timeZone) { final parsed = DateTime.tryParse(value ?? ''); if (parsed == null || value == null || !value.contains('T')) { @@ -67,6 +104,24 @@ bool providerDateTimeIsInstant(String? value, String? timeZone) { return parsed.isUtc || isUtcTimeZone(timeZone); } +DateTime providerUtcInstantAsWallTime(DateTime value, String? timeZone) { + final instant = value.toUtc(); + if (isUtcTimeZone(timeZone)) return instant; + final location = _timeZoneLocation(timeZone); + if (location == null) return instant.toLocal(); + final zoned = time_zone.TZDateTime.from(instant, location); + return DateTime( + zoned.year, + zoned.month, + zoned.day, + zoned.hour, + zoned.minute, + zoned.second, + zoned.millisecond, + zoned.microsecond, + ); +} + bool isUtcTimeZone(String? timeZone) { final normalizedZone = timeZone?.trim().toLowerCase(); return normalizedZone == 'utc' || @@ -76,14 +131,9 @@ bool isUtcTimeZone(String? timeZone) { } DateTime? _providerWallTimeAsUtc(DateTime wall, String? timeZoneId) { - final id = timeZoneId?.trim(); - if (id == null || id.isEmpty) return null; - if (!_timeZonesInitialized) { - time_zone_data.initializeTimeZones(); - _timeZonesInitialized = true; - } + final location = _timeZoneLocation(timeZoneId); + if (location == null) return null; try { - final location = time_zone.getLocation(id); final instant = time_zone.TZDateTime( location, wall.year, @@ -99,6 +149,20 @@ DateTime? _providerWallTimeAsUtc(DateTime wall, String? timeZoneId) { instant.millisecondsSinceEpoch, isUtc: true, ); + } on ArgumentError { + return null; + } +} + +time_zone.Location? _timeZoneLocation(String? timeZoneId) { + final id = timeZoneId?.trim(); + if (id == null || id.isEmpty) return null; + if (!_timeZonesInitialized) { + time_zone_data.initializeTimeZones(); + _timeZonesInitialized = true; + } + try { + return time_zone.getLocation(id); } on time_zone.LocationNotFoundException { // Some providers use platform-specific zone labels. Preserve their prior // local-wall-time behavior when no IANA definition is available. diff --git a/lib/src/core/time/system_time_zone_location.dart b/lib/src/core/time/system_time_zone_location.dart new file mode 100644 index 00000000..dbeb87f4 --- /dev/null +++ b/lib/src/core/time/system_time_zone_location.dart @@ -0,0 +1,21 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class BusyMaxSystemTimeZoneLocation { + const BusyMaxSystemTimeZoneLocation({ + required this.name, + required this.englishName, + required this.countryCode, + required this.timeZoneId, + }); + + final String name; + final String englishName; + final String? countryCode; + final String timeZoneId; + + bool matches(String normalizedQuery) { + return name.toLowerCase().contains(normalizedQuery) || + englishName.toLowerCase().contains(normalizedQuery); + } +} diff --git a/lib/src/core/time/time_zone_catalog.dart b/lib/src/core/time/time_zone_catalog.dart index b59f4e6c..b71362b5 100644 --- a/lib/src/core/time/time_zone_catalog.dart +++ b/lib/src/core/time/time_zone_catalog.dart @@ -2,7 +2,10 @@ import 'package:flutter/foundation.dart' show immutable, visibleForTesting; import 'package:timezone/data/latest_all.dart' as time_zone_data; import 'package:timezone/timezone.dart' as time_zone; -import 'linux_gweather_location_source.dart'; +import 'system_time_zone_location.dart'; + +typedef BusyMaxSystemTimeZoneLocationLoader = + Future> Function(); @immutable class BusyMaxTimeZoneLocation { @@ -72,6 +75,17 @@ abstract final class BusyMaxTimeZoneCatalog { static List? _systemLocations; static Future>? _systemLocationLoad; static List? _locationSearchOptions; + static BusyMaxSystemTimeZoneLocationLoader _systemLocationLoader = + _emptySystemLocations; + + static void configureSystemLocationLoader( + BusyMaxSystemTimeZoneLocationLoader loader, + ) { + _systemLocationLoader = loader; + _systemLocations = null; + _systemLocationLoad = null; + _locationSearchOptions = null; + } static List get locations { return _locations ??= _buildLocations(); @@ -103,10 +117,13 @@ abstract final class BusyMaxTimeZoneCatalog { static bool get isLocationSearchReady => _systemLocations != null; static Future prepareLocationSearch() async { - final load = _systemLocationLoad ??= loadLinuxSystemTimeZoneLocations(); + final load = _systemLocationLoad ??= _systemLocationLoader(); _systemLocations ??= await load; } + static Future> + _emptySystemLocations() async => const []; + static Future> searchLocations( String query, ) async { diff --git a/lib/src/dav/dav_provider_profile.dart b/lib/src/dav/dav_provider_profile.dart index 1a69ddbe..103aedd1 100644 --- a/lib/src/dav/dav_provider_profile.dart +++ b/lib/src/dav/dav_provider_profile.dart @@ -37,7 +37,9 @@ final class DavProviderProfile { BusyProvider.nextcloud => _sameOrigin(destination, accountAuthority) && _preservesInstallationPath(destination, accountAuthority), - BusyProvider.google || BusyProvider.microsoft => false, + BusyProvider.google || + BusyProvider.microsoft || + BusyProvider.webCal => false, }; } } @@ -76,7 +78,9 @@ DavProviderProfile davProviderProfile( allowSchedulingMutations: false, allowMove: true, ), - BusyProvider.google || BusyProvider.microsoft => throw ArgumentError.value( + BusyProvider.google || + BusyProvider.microsoft || + BusyProvider.webCal => throw ArgumentError.value( provider, 'provider', 'The provider does not use the DAV transport.', diff --git a/lib/src/dav/discovery/dav_discovery_repository.dart b/lib/src/dav/discovery/dav_discovery_repository.dart index a5d9fcf8..396c6205 100644 --- a/lib/src/dav/discovery/dav_discovery_repository.dart +++ b/lib/src/dav/discovery/dav_discovery_repository.dart @@ -18,8 +18,11 @@ final class DavDiscoveryRepository { final AppDatabase _database; final String Function() _idFactory; - Future commitSuccessfulInventory(DavDiscoveryResult result) { + /// Returns whether discovery changed a notification-enabled projection. + Future commitSuccessfulInventory(DavDiscoveryResult result) { return _database.transaction(() async { + final notificationProjectionsBefore = + await _notificationEnabledProjectionKeys(result.accountId); final service = result.service; await _database .into(_database.davAccountServices) @@ -164,9 +167,43 @@ final class DavDiscoveryRepository { updatedAtUtc: Value(service.discoveredAtUtc.toIso8601String()), ), ); + final notificationProjectionsAfter = + await _notificationEnabledProjectionKeys(result.accountId); + return !_sameProjectionKeys( + notificationProjectionsBefore, + notificationProjectionsAfter, + ); }); } + Future> _notificationEnabledProjectionKeys( + String accountId, + ) async { + final eventSources = + await (_database.select(_database.calendarSources)..where( + (row) => + row.accountId.equals(accountId) & + row.davCollectionId.isNotNull() & + row.remindersEnabled.equals(true) & + row.isDeleted.equals(false), + )) + .get(); + final taskLists = + await (_database.select(_database.taskLists)..where( + (row) => + row.accountId.equals(accountId) & + row.davCollectionId.isNotNull() & + row.remindersEnabled.equals(true) & + row.pendingDelete.equals(false) & + row.serverMissing.equals(false), + )) + .get(); + return { + for (final source in eventSources) 'event:${source.id}', + for (final list in taskLists) 'task:${list.id}', + }; + } + Future recordDiscoveryFailure(String accountId, String code) async { final existing = await (_database.select( _database.davAccountServices, @@ -295,6 +332,9 @@ final class DavDiscoveryRepository { } } +bool _sameProjectionKeys(Set left, Set right) => + left.length == right.length && left.containsAll(right); + String _projectionMetadata(DavCollectionDiscovery collection) => jsonEncode({ 'transport': 'caldav', 'hrefKey': collection.hrefKey, diff --git a/lib/src/dav/ical/ical_recurrence.dart b/lib/src/dav/ical/ical_recurrence.dart index 720d0340..a63c709c 100644 --- a/lib/src/dav/ical/ical_recurrence.dart +++ b/lib/src/dav/ical/ical_recurrence.dart @@ -27,6 +27,7 @@ final class IcalOccurrence { const IcalOccurrence({ required this.master, required this.override, + required this.inheritedOverride, required this.recurrenceId, required this.start, required this.end, @@ -35,18 +36,29 @@ final class IcalOccurrence { final IcalSemanticComponent master; final IcalSemanticComponent? override; + final IcalSemanticComponent? inheritedOverride; final IcalTemporalValue recurrenceId; final IcalTemporalValue start; final IcalTemporalValue? end; final String occurrenceKey; - IcalSemanticComponent get effectiveComponent => override ?? master; + IcalSemanticComponent get effectiveComponent => + override ?? inheritedOverride ?? master; + IcalSemanticComponent get identityComponent => override ?? master; bool get isException => override != null; - bool get isCancelled => override?.isCancelled ?? master.isCancelled; - - String? get summary => override?.summary ?? master.summary; - String? get description => override?.description ?? master.description; - String? get location => override?.location ?? master.location; + bool get isCancelled => + override?.isCancelled ?? + inheritedOverride?.isCancelled ?? + master.isCancelled; + + String? get summary => + override?.summary ?? inheritedOverride?.summary ?? master.summary; + String? get description => + override?.description ?? + inheritedOverride?.description ?? + master.description; + String? get location => + override?.location ?? inheritedOverride?.location ?? master.location; } /// Expands an RFC 5545 recurrence set from its authoritative resource. @@ -168,6 +180,12 @@ final class IcalRecurrenceExpander { starts.putIfAbsent(key, () => recurrenceId); } + final rangeOverrides = + overrides.values + .where((component) => component.recurrenceRange == 'THISANDFUTURE') + .toList(growable: false) + ..sort((left, right) => _compareRecurrenceIds(left, right)); + if (starts.length > limits.maximumOccurrences) { throw _occurrenceLimitError(); } @@ -176,13 +194,22 @@ final class IcalRecurrenceExpander { for (final entry in starts.entries) { final originalStart = entry.value; final exception = overrides[entry.key]; + final inheritedOverride = exception == null + ? _rangeOverrideFor(originalStart, rangeOverrides) + : null; final effectiveStart = - exception?.start ?? exception?.due ?? originalStart; - final effectiveEnd = _occurrenceEnd( - exception, - effectiveStart, - masterDuration, - ); + exception?.start ?? + exception?.due ?? + _rangeAdjustedStart(originalStart, inheritedOverride) ?? + originalStart; + final effectiveEnd = exception != null + ? _occurrenceEnd(exception, effectiveStart, masterDuration) + : _rangeOccurrenceEnd( + inheritedOverride, + effectiveStart, + masterDuration, + timeZoneResolver, + ); if (!_overlaps( effectiveStart, effectiveEnd, @@ -196,6 +223,7 @@ final class IcalRecurrenceExpander { IcalOccurrence( master: master, override: exception, + inheritedOverride: inheritedOverride, recurrenceId: originalStart, start: effectiveStart, end: effectiveEnd, @@ -786,6 +814,67 @@ IcalTemporalValue? _occurrenceEnd( return _withWallValue(start, start.localValue.add(duration)); } +int _compareRecurrenceIds( + IcalSemanticComponent left, + IcalSemanticComponent right, +) => left.recurrenceId!.localValue.compareTo(right.recurrenceId!.localValue); + +IcalSemanticComponent? _rangeOverrideFor( + IcalTemporalValue recurrenceId, + List rangeOverrides, +) { + IcalSemanticComponent? active; + for (final candidate in rangeOverrides) { + if (candidate.recurrenceId!.localValue.isAfter(recurrenceId.localValue)) { + break; + } + active = candidate; + } + return active; +} + +IcalTemporalValue? _rangeAdjustedStart( + IcalTemporalValue originalStart, + IcalSemanticComponent? rangeOverride, +) { + final recurrenceId = rangeOverride?.recurrenceId; + final rangeStart = rangeOverride?.start ?? rangeOverride?.due; + if (recurrenceId == null || rangeStart == null) return null; + final delta = _wallDateTime( + rangeStart.localValue, + ).difference(_wallDateTime(recurrenceId.localValue)); + return _withWallValue( + rangeStart, + _wallDateTime(originalStart.localValue).add(delta), + ); +} + +IcalTemporalValue? _rangeOccurrenceEnd( + IcalSemanticComponent? rangeOverride, + IcalTemporalValue start, + Duration masterDuration, + IcalTimeZoneResolver timeZoneResolver, +) { + final hasRangeDuration = + rangeOverride?.duration != null || rangeOverride?.end != null; + final duration = hasRangeDuration + ? _componentDuration(rangeOverride!, timeZoneResolver) + : masterDuration; + if (duration == Duration.zero) return null; + return _withWallValue(start, start.localValue.add(duration)); +} + +DateTime _wallDateTime(DateTime value) => DateTime.utc( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.millisecond, + value.microsecond, +); + bool _overlaps( IcalTemporalValue start, IcalTemporalValue? end, diff --git a/lib/src/dav/ical/ical_semantics.dart b/lib/src/dav/ical/ical_semantics.dart index 7da67ddb..db572c3f 100644 --- a/lib/src/dav/ical/ical_semantics.dart +++ b/lib/src/dav/ical/ical_semantics.dart @@ -67,6 +67,7 @@ final class IcalSemanticComponent { required this.uid, required this.recurrenceIdKey, required this.recurrenceId, + required this.recurrenceRange, required this.summary, required this.description, required this.location, @@ -123,6 +124,10 @@ final class IcalSemanticComponent { component.firstProperty('RECURRENCE-ID'), ), recurrenceId: parseIcalTemporal(component.firstProperty('RECURRENCE-ID')), + recurrenceRange: component + .firstProperty('RECURRENCE-ID') + ?.parameterValue('RANGE') + ?.toUpperCase(), summary: _text(component, 'SUMMARY'), description: _text(component, 'DESCRIPTION'), location: _text(component, 'LOCATION'), @@ -160,6 +165,7 @@ final class IcalSemanticComponent { final String? uid; final String? recurrenceIdKey; final IcalTemporalValue? recurrenceId; + final String? recurrenceRange; final String? summary; final String? description; final String? location; @@ -251,7 +257,15 @@ final class IcalSemanticDocument { }); factory IcalSemanticDocument.parse(String rawIcs) { - final document = IcalDocument.parse(rawIcs); + return IcalSemanticDocument.fromDocument(IcalDocument.parse(rawIcs)); + } + + /// Builds the CalDAV semantic view from an already parsed document. + /// + /// This retains the existing one-component-type, one-UID recurrence-set + /// invariant. Multi-UID calendar ingestion must split a snapshot into + /// recurrence-set documents before using this factory. + factory IcalSemanticDocument.fromDocument(IcalDocument document) { final semanticComponents = []; final timeZones = []; for (final component in document.calendarComponents) { diff --git a/lib/src/dav/ical/ical_task_recurrence.dart b/lib/src/dav/ical/ical_task_recurrence.dart index 737bb6d9..ff04e688 100644 --- a/lib/src/dav/ical/ical_task_recurrence.dart +++ b/lib/src/dav/ical/ical_task_recurrence.dart @@ -1,485 +1,4 @@ -import 'dart:convert'; +import '../../features/recurrence/domain/recurrence_rule.dart'; -enum IcalTaskRecurrenceFrequency { none, daily, weekly, monthly, yearly } - -/// The recurrence subset exposed by Nextcloud Tasks 0.18.x. -/// -/// Rules outside that subset remain available through [rawRules] and are not -/// rewritten. Supported rules cover frequency, interval, BYDAY, BYMONTH, -/// BYMONTHDAY, BYSETPOS, COUNT, and UNTIL. -final class IcalTaskRecurrence { - const IcalTaskRecurrence({ - required this.frequency, - required this.interval, - required this.byDay, - required this.byMonth, - required this.byMonthDay, - required this.bySetPosition, - required this.count, - required this.untilRaw, - required this.recurrenceDates, - required this.exceptionDates, - required this.rawRules, - required this.isSupported, - }); - - const IcalTaskRecurrence.none() - : frequency = IcalTaskRecurrenceFrequency.none, - interval = 1, - byDay = const [], - byMonth = const [], - byMonthDay = const [], - bySetPosition = null, - count = null, - untilRaw = null, - recurrenceDates = const [], - exceptionDates = const [], - rawRules = const [], - isSupported = true; - - factory IcalTaskRecurrence.fromJson(String? source, {DateTime? baseDate}) { - if (source == null || source.isEmpty) { - return const IcalTaskRecurrence.none(); - } - try { - final decoded = jsonDecode(source); - if (decoded is! Map) return _unsupported(const [], const [], const []); - final map = decoded.cast(); - final rules = _strings(map['rules']); - final dates = _strings(map['dates']); - final excludedDates = _strings(map['excludedDates']); - if (rules.isEmpty) { - return IcalTaskRecurrence( - frequency: IcalTaskRecurrenceFrequency.none, - interval: 1, - byDay: const [], - byMonth: const [], - byMonthDay: const [], - bySetPosition: null, - count: null, - untilRaw: null, - recurrenceDates: dates, - exceptionDates: excludedDates, - rawRules: rules, - isSupported: true, - ); - } - if (rules.length != 1) return _unsupported(rules, dates, excludedDates); - final parts = {}; - for (final segment in rules.single.split(';')) { - final separator = segment.indexOf('='); - if (separator <= 0 || separator == segment.length - 1) { - return _unsupported(rules, dates, excludedDates); - } - final key = segment.substring(0, separator).toUpperCase(); - if (parts.containsKey(key) || !_supportedParts.contains(key)) { - return _unsupported(rules, dates, excludedDates); - } - parts[key] = segment.substring(separator + 1).toUpperCase(); - } - final frequency = switch (parts['FREQ']) { - 'DAILY' => IcalTaskRecurrenceFrequency.daily, - 'WEEKLY' => IcalTaskRecurrenceFrequency.weekly, - 'MONTHLY' => IcalTaskRecurrenceFrequency.monthly, - 'YEARLY' => IcalTaskRecurrenceFrequency.yearly, - _ => null, - }; - final interval = int.tryParse(parts['INTERVAL'] ?? '1'); - final count = parts['COUNT'] == null - ? null - : int.tryParse(parts['COUNT']!); - final byDay = _csv(parts['BYDAY']); - final byMonth = _integers(parts['BYMONTH']); - final byMonthDay = _integers(parts['BYMONTHDAY']); - final bySetPositions = _integers(parts['BYSETPOS']); - final until = parts['UNTIL']; - final valid = - frequency != null && - interval != null && - interval >= 1 && - interval <= 366 && - (count == null || (count >= 1 && count <= 3500)) && - !(count != null && until != null) && - (until == null || _validUntil(until)) && - byMonth.every((value) => value >= 1 && value <= 12) && - byMonthDay.every( - (value) => value != 0 && value >= -31 && value <= 31, - ) && - bySetPositions.length <= 1 && - bySetPositions.every( - (value) => value != 0 && value >= -366 && value <= 366, - ); - if (!valid) return _unsupported(rules, dates, excludedDates); - final supportedFrequency = frequency; - final supportedInterval = interval; - - final normalized = _normalizeEditorRule( - frequency: supportedFrequency, - byDay: byDay, - byMonth: byMonth, - byMonthDay: byMonthDay, - bySetPositions: bySetPositions, - baseDate: baseDate, - ); - if (normalized == null) { - return _unsupported(rules, dates, excludedDates); - } - return IcalTaskRecurrence( - frequency: supportedFrequency, - interval: supportedInterval, - byDay: List.unmodifiable(normalized.byDay), - byMonth: List.unmodifiable(normalized.byMonth), - byMonthDay: List.unmodifiable(normalized.byMonthDay), - bySetPosition: normalized.bySetPosition, - count: count, - untilRaw: until, - recurrenceDates: List.unmodifiable(dates), - exceptionDates: List.unmodifiable(excludedDates), - rawRules: List.unmodifiable(rules), - isSupported: true, - ); - } on Object { - return _unsupported(const [], const [], const []); - } - } - - final IcalTaskRecurrenceFrequency frequency; - final int interval; - final List byDay; - final List byMonth; - final List byMonthDay; - final int? bySetPosition; - final int? count; - final String? untilRaw; - final List recurrenceDates; - final List exceptionDates; - final List rawRules; - final bool isSupported; - - bool get repeats => frequency != IcalTaskRecurrenceFrequency.none; - - String? get untilDate { - final value = untilRaw; - if (value == null || value.length < 8) return null; - final parsed = _parseUntil(value); - if (parsed == null) return null; - final display = value.endsWith('Z') ? parsed.toLocal() : parsed; - return '${display.year.toString().padLeft(4, '0')}-' - '${display.month.toString().padLeft(2, '0')}-' - '${display.day.toString().padLeft(2, '0')}'; - } - - IcalTaskRecurrence copyWith({ - IcalTaskRecurrenceFrequency? frequency, - int? interval, - List? byDay, - List? byMonth, - List? byMonthDay, - Object? bySetPosition = _unchanged, - Object? count = _unchanged, - Object? untilRaw = _unchanged, - List? recurrenceDates, - List? exceptionDates, - }) => IcalTaskRecurrence( - frequency: frequency ?? this.frequency, - interval: interval ?? this.interval, - byDay: List.unmodifiable(byDay ?? this.byDay), - byMonth: List.unmodifiable(byMonth ?? this.byMonth), - byMonthDay: List.unmodifiable(byMonthDay ?? this.byMonthDay), - bySetPosition: bySetPosition == _unchanged - ? this.bySetPosition - : bySetPosition as int?, - count: count == _unchanged ? this.count : count as int?, - untilRaw: untilRaw == _unchanged ? this.untilRaw : untilRaw as String?, - recurrenceDates: List.unmodifiable(recurrenceDates ?? this.recurrenceDates), - exceptionDates: List.unmodifiable(exceptionDates ?? this.exceptionDates), - rawRules: const [], - isSupported: true, - ); - - IcalTaskRecurrence withUntilDate(String? date, {required bool allDay}) { - if (date == null || date.isEmpty) return copyWith(untilRaw: null); - final parsed = DateTime.tryParse(date); - if (parsed == null) return this; - final localDate = DateTime(parsed.year, parsed.month, parsed.day); - final basic = _basicDate(localDate); - if (allDay) return copyWith(untilRaw: basic); - final utc = localDate.toUtc(); - return copyWith( - untilRaw: - '${_basicDate(utc)}T' - '${utc.hour.toString().padLeft(2, '0')}' - '${utc.minute.toString().padLeft(2, '0')}' - '${utc.second.toString().padLeft(2, '0')}Z', - ); - } - - String toJsonString() => jsonEncode({ - 'rules': repeats ? [toRrule()] : const [], - 'dates': recurrenceDates, - 'excludedDates': exceptionDates, - }); - - String toRrule() { - if (!isSupported || !repeats) { - throw StateError('Only a supported repeating rule can be serialized.'); - } - final result = [ - 'FREQ=${frequency.name.toUpperCase()}', - 'INTERVAL=$interval', - if (byDay.isNotEmpty) 'BYDAY=${byDay.join(',')}', - if (byMonth.isNotEmpty) 'BYMONTH=${byMonth.join(',')}', - if (byMonthDay.isNotEmpty) 'BYMONTHDAY=${byMonthDay.join(',')}', - if (bySetPosition != null) 'BYSETPOS=$bySetPosition', - if (count != null) 'COUNT=$count', - if (untilRaw != null) 'UNTIL=$untilRaw', - ]; - return result.join(';'); - } -} - -const _supportedParts = { - 'FREQ', - 'INTERVAL', - 'BYDAY', - 'BYMONTH', - 'BYMONTHDAY', - 'BYSETPOS', - 'COUNT', - 'UNTIL', -}; - -const _unchanged = Object(); - -IcalTaskRecurrence _unsupported( - List rules, - List dates, - List excludedDates, -) => IcalTaskRecurrence( - frequency: IcalTaskRecurrenceFrequency.none, - interval: 1, - byDay: const [], - byMonth: const [], - byMonthDay: const [], - bySetPosition: null, - count: null, - untilRaw: null, - recurrenceDates: List.unmodifiable(dates), - exceptionDates: List.unmodifiable(excludedDates), - rawRules: List.unmodifiable(rules), - isSupported: false, -); - -List _strings(Object? source) { - if (source == null) return const []; - if (source is! List) throw const FormatException(); - return [for (final value in source) value.toString()]; -} - -List _csv(String? source) => source == null || source.isEmpty - ? const [] - : source.split(',').where((value) => value.isNotEmpty).toList(); - -List _integers(String? source) { - final values = _csv(source); - final result = []; - for (final value in values) { - final parsed = int.tryParse(value); - if (parsed == null) throw const FormatException(); - result.add(parsed); - } - return result; -} - -typedef _NormalizedEditorRule = ({ - List byDay, - List byMonth, - List byMonthDay, - int? bySetPosition, -}); - -_NormalizedEditorRule? _normalizeEditorRule({ - required IcalTaskRecurrenceFrequency frequency, - required List byDay, - required List byMonth, - required List byMonthDay, - required List bySetPositions, - required DateTime? baseDate, -}) { - switch (frequency) { - case IcalTaskRecurrenceFrequency.none: - return null; - case IcalTaskRecurrenceFrequency.daily: - if (byDay.isNotEmpty || - byMonth.isNotEmpty || - byMonthDay.isNotEmpty || - bySetPositions.isNotEmpty) { - return null; - } - return ( - byDay: const [], - byMonth: const [], - byMonthDay: const [], - bySetPosition: null, - ); - case IcalTaskRecurrenceFrequency.weekly: - if (byMonth.isNotEmpty || - byMonthDay.isNotEmpty || - bySetPositions.isNotEmpty || - byDay.any((value) => !_plainWeekdays.contains(value))) { - return null; - } - return ( - byDay: byDay.isEmpty && baseDate != null - ? [_weekdayFor(baseDate.weekday)] - : byDay, - byMonth: const [], - byMonthDay: const [], - bySetPosition: null, - ); - case IcalTaskRecurrenceFrequency.monthly: - if (byMonth.isNotEmpty) return null; - return _normalizeMonthlyOrYearly( - byDay: byDay, - byMonth: const [], - byMonthDay: byMonthDay, - bySetPositions: bySetPositions, - defaultMonthDay: baseDate?.day, - ); - case IcalTaskRecurrenceFrequency.yearly: - final months = byMonth.isEmpty && baseDate != null - ? [baseDate.month] - : byMonth; - if (months.isEmpty) return null; - return _normalizeMonthlyOrYearly( - byDay: byDay, - byMonth: months, - byMonthDay: byMonthDay, - bySetPositions: bySetPositions, - defaultMonthDay: baseDate?.day, - ); - } -} - -_NormalizedEditorRule? _normalizeMonthlyOrYearly({ - required List byDay, - required List byMonth, - required List byMonthDay, - required List bySetPositions, - required int? defaultMonthDay, -}) { - if (byMonthDay.isNotEmpty) { - if (byDay.isNotEmpty || - bySetPositions.isNotEmpty || - byMonthDay.any((value) => value < 1 || value > 31)) { - return null; - } - return ( - byDay: const [], - byMonth: byMonth, - byMonthDay: byMonthDay, - bySetPosition: null, - ); - } - - if (byDay.length == 1 && bySetPositions.isEmpty) { - final ordinal = RegExp( - r'^(-?[1-5])(MO|TU|WE|TH|FR|SA|SU)$', - ).firstMatch(byDay.single); - if (ordinal != null) { - final position = int.parse(ordinal.group(1)!); - if (_editorSetPositions.contains(position)) { - return ( - byDay: [ordinal.group(2)!], - byMonth: byMonth, - byMonthDay: const [], - bySetPosition: position, - ); - } - } - } - - if (byDay.isNotEmpty && - bySetPositions.length == 1 && - _allowedEditorByDay(byDay) && - _editorSetPositions.contains(bySetPositions.single)) { - return ( - byDay: byDay, - byMonth: byMonth, - byMonthDay: const [], - bySetPosition: bySetPositions.single, - ); - } - - if (byDay.isEmpty && bySetPositions.isEmpty && defaultMonthDay != null) { - return ( - byDay: const [], - byMonth: byMonth, - byMonthDay: [defaultMonthDay], - bySetPosition: null, - ); - } - return null; -} - -bool _allowedEditorByDay(List values) { - final sorted = [...values]..sort(); - return _editorDaySets.any((allowed) { - final candidate = [...allowed]..sort(); - if (candidate.length != sorted.length) return false; - for (var index = 0; index < sorted.length; index += 1) { - if (candidate[index] != sorted[index]) return false; - } - return true; - }); -} - -bool _validUntil(String value) => _parseUntil(value) != null; - -DateTime? _parseUntil(String value) { - final match = RegExp( - r'^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$', - ).firstMatch(value); - if (match == null) return null; - final year = int.parse(match.group(1)!); - final month = int.parse(match.group(2)!); - final day = int.parse(match.group(3)!); - final hasTime = match.group(4) != null; - final hour = hasTime ? int.parse(match.group(4)!) : 0; - final minute = hasTime ? int.parse(match.group(5)!) : 0; - final second = hasTime ? int.parse(match.group(6)!) : 0; - final parsed = match.group(7) == 'Z' - ? DateTime.utc(year, month, day, hour, minute, second) - : DateTime(year, month, day, hour, minute, second); - if (parsed.year != year || - parsed.month != month || - parsed.day != day || - parsed.hour != hour || - parsed.minute != minute || - parsed.second != second) { - return null; - } - return parsed; -} - -String _basicDate(DateTime value) => - '${value.year.toString().padLeft(4, '0')}' - '${value.month.toString().padLeft(2, '0')}' - '${value.day.toString().padLeft(2, '0')}'; - -String _weekdayFor(int weekday) => _plainWeekdays[(weekday - 1).clamp(0, 6)]; - -const _plainWeekdays = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']; -const _editorSetPositions = {1, 2, 3, 4, 5, -2, -1}; -const _editorDaySets = >[ - ['MO'], - ['TU'], - ['WE'], - ['TH'], - ['FR'], - ['SA'], - ['SU'], - ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'], - ['MO', 'TU', 'WE', 'TH', 'FR'], - ['SA', 'SU'], -]; +typedef IcalTaskRecurrence = RecurrenceRule; +typedef IcalTaskRecurrenceFrequency = RecurrenceFrequency; diff --git a/lib/src/dav/mutation/dav_calendar_collection_mutation_service.dart b/lib/src/dav/mutation/dav_calendar_collection_mutation_service.dart new file mode 100644 index 00000000..2f1938df --- /dev/null +++ b/lib/src/dav/mutation/dav_calendar_collection_mutation_service.dart @@ -0,0 +1,431 @@ +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:http/http.dart' as http; +import 'package:uuid/uuid.dart'; + +import '../../core/secrets/secret_store.dart'; +import '../../db/app_database.dart'; +import '../../providers/busy_provider.dart'; +import '../dav_errors.dart'; +import '../dav_provider_profile.dart'; +import '../http/dav_http_transport.dart'; +import '../xml/dav_xml.dart'; +import 'dav_collection_mutation_helpers.dart'; + +enum DavCalendarCollectionCreationStatus { created, createdRefreshPending } + +final class DavCalendarCollectionCreationResult { + const DavCalendarCollectionCreationResult(this.status); + + final DavCalendarCollectionCreationStatus status; + + bool get refreshPending => + status == DavCalendarCollectionCreationStatus.createdRefreshPending; +} + +abstract interface class DavCalendarCollectionMutationClient { + Future createEventCalendar( + String title, { + DavCancellationToken? cancellationToken, + }); +} + +/// Creates a Nextcloud VEVENT collection, then lets normal DAV discovery own +/// all local collection and calendar-source projection. +final class DavCalendarCollectionMutationService + implements DavCalendarCollectionMutationClient { + DavCalendarCollectionMutationService({ + required AppDatabase database, + required SecretStore secretStore, + required http.Client httpClient, + required String accountId, + required Future Function() refreshAfterMutation, + Future Function()? requireNetwork, + DavTransportLimits transportLimits = const DavTransportLimits(), + DavXmlParser xmlParser = const DavXmlParser(), + String Function()? correlationIdFactory, + }) : _database = database, + _secretStore = secretStore, + _httpClient = httpClient, + _accountId = accountId, + _refreshAfterMutation = refreshAfterMutation, + _requireNetwork = requireNetwork ?? _noNetworkCheck, + _transportLimits = transportLimits, + _xmlParser = xmlParser, + _correlationIdFactory = correlationIdFactory ?? const Uuid().v4; + + final AppDatabase _database; + final SecretStore _secretStore; + final http.Client _httpClient; + final String _accountId; + final Future Function() _refreshAfterMutation; + final Future Function() _requireNetwork; + final DavTransportLimits _transportLimits; + final DavXmlParser _xmlParser; + final String Function() _correlationIdFactory; + + @override + Future createEventCalendar( + String title, { + DavCancellationToken? cancellationToken, + }) async { + final displayName = _requiredCalendarTitle(title); + cancellationToken?.throwIfCancelled(); + await _requireNetwork(); + final context = await _loadContext(); + final existing = await _activeCollections(); + if (existing.any( + (collection) => + collection.eventProjectionEnabled && + collection.displayName == displayName, + )) { + throw const DavException( + kind: DavErrorKind.conflict, + code: 'DavCalendarNameConflict', + safeMessage: 'A Nextcloud calendar already uses this name.', + ); + } + + final homeUri = davCollectionUri(context.calendarHomeUri); + final memberName = nextcloudCollectionMemberName( + displayName, + homeUri: homeUri, + existingCollectionUris: [ + for (final collection in existing) Uri.parse(collection.requestUri), + ], + ); + final targetUri = homeUri.resolve('$memberName/'); + _validateTarget( + profile: context.profile, + authority: context.authority, + calendarHome: homeUri, + target: targetUri, + ); + + try { + final response = await context.transport.send( + DavRequest.xml( + method: 'MKCALENDAR', + uri: targetUri, + accountId: _accountId, + correlationId: _correlationIdFactory(), + body: _eventCalendarMkcalendarXml(displayName), + retryClass: DavRetryClass.never, + ), + credential: context.credential, + cancellationToken: cancellationToken, + ); + if (response.statusCode != HttpStatus.created) { + throw _calendarCreationStatusError( + response.statusCode, + correlationId: response.correlationId, + ); + } + } on DavException catch (error) { + if (!_calendarCreationMayHaveCommitted(error) || + !await _isMatchingEventCalendar( + context, + targetUri, + displayName: displayName, + cancellationToken: cancellationToken, + )) { + rethrow; + } + } + + try { + await _refreshAfterMutation(); + return const DavCalendarCollectionCreationResult( + DavCalendarCollectionCreationStatus.created, + ); + } on Object { + return const DavCalendarCollectionCreationResult( + DavCalendarCollectionCreationStatus.createdRefreshPending, + ); + } + } + + Future<_DavCalendarCollectionContext> _loadContext() async { + final account = await (_database.select( + _database.accounts, + )..where((row) => row.id.equals(_accountId))).getSingleOrNull(); + if (account == null || + BusyProviderCodec.requireStorageValue(account.provider) != + BusyProvider.nextcloud) { + throw const DavException( + kind: DavErrorKind.protocol, + code: 'DavCalendarMutationRequiresNextcloud', + safeMessage: 'Calendar collection creation requires Nextcloud.', + ); + } + final authority = Uri.tryParse(account.authority); + if (authority == null) { + throw _invalidCalendarMutationContext(); + } + final secret = await _secretStore.readCredential(_accountId); + if (secret is! NextcloudSecretRecord || + secret.canonicalServer != authority || + secret.loginName != account.providerAccountId) { + throw const DavException( + kind: DavErrorKind.authentication, + code: 'DavCredentialsRevoked', + safeMessage: 'The Nextcloud credential is unavailable or invalid.', + ); + } + final service = await (_database.select( + _database.davAccountServices, + )..where((row) => row.accountId.equals(_accountId))).getSingleOrNull(); + final calendarHomeValue = service?.calendarHomeHref?.trim(); + final calendarHome = calendarHomeValue == null || calendarHomeValue.isEmpty + ? null + : Uri.tryParse(calendarHomeValue); + if (calendarHome == null) { + throw const DavException( + kind: DavErrorKind.protocol, + code: 'DavCalendarHomeUnavailable', + safeMessage: 'The Nextcloud calendar home is unavailable.', + ); + } + final profile = davProviderProfile( + BusyProvider.nextcloud, + nextcloudServer: authority, + ); + if (!profile.allowCollectionMutations || + !profile.isTrustedCredentialDestination( + calendarHome, + accountAuthority: authority, + )) { + throw _invalidCalendarMutationContext(); + } + return _DavCalendarCollectionContext( + authority: authority, + profile: profile, + transport: DavHttpTransport( + client: _httpClient, + profile: profile, + accountAuthority: authority, + limits: _transportLimits, + ), + credential: DavBasicCredential( + username: secret.loginName, + password: secret.appPassword, + ), + calendarHomeUri: calendarHome, + ); + } + + Future> _activeCollections() { + return (_database.select(_database.davCollections)..where( + (row) => + row.accountId.equals(_accountId) & + row.deleted.equals(false) & + row.serverMissing.equals(false), + )) + .get(); + } + + Future _isMatchingEventCalendar( + _DavCalendarCollectionContext context, + Uri uri, { + required String displayName, + DavCancellationToken? cancellationToken, + }) async { + try { + final response = await context.transport.send( + DavRequest.xml( + method: 'PROPFIND', + uri: uri, + accountId: _accountId, + correlationId: _correlationIdFactory(), + headers: const {'depth': '0'}, + body: _eventCalendarProbeXml, + retryClass: DavRetryClass.safeRead, + ), + credential: context.credential, + cancellationToken: cancellationToken, + ); + if (response.statusCode != HttpStatus.multiStatus) return false; + final multistatus = _xmlParser.parseMultistatus( + response.bodyBytes, + correlationId: response.correlationId, + ); + for (final item in multistatus.responses) { + final resourceType = item.successfulProperty( + davNamespace, + 'resourcetype', + ); + final components = item.successfulProperty( + caldavNamespace, + 'supported-calendar-component-set', + ); + final name = item + .successfulProperty(davNamespace, 'displayname') + ?.text + .trim(); + final isCollection = resourceType?.childElements.any( + (element) => + element.name.namespaceUri == davNamespace && + element.name.local == 'collection', + ); + final isCalendar = resourceType?.childElements.any( + (element) => + element.name.namespaceUri == caldavNamespace && + element.name.local == 'calendar', + ); + final supportsEvents = components?.childElements.any( + (element) => + element.name.namespaceUri == caldavNamespace && + element.name.local == 'comp' && + element.getAttribute('name')?.toUpperCase() == 'VEVENT', + ); + if (isCollection == true && + isCalendar == true && + supportsEvents == true && + name == displayName) { + return true; + } + } + } on Object { + return false; + } + return false; + } +} + +final class _DavCalendarCollectionContext { + const _DavCalendarCollectionContext({ + required this.authority, + required this.profile, + required this.transport, + required this.credential, + required this.calendarHomeUri, + }); + + final Uri authority; + final DavProviderProfile profile; + final DavHttpTransport transport; + final DavBasicCredential credential; + final Uri calendarHomeUri; +} + +void _validateTarget({ + required DavProviderProfile profile, + required Uri authority, + required Uri calendarHome, + required Uri target, +}) { + final home = davCollectionUri(calendarHome); + final targetPath = target.path; + final relativePath = targetPath.startsWith(home.path) + ? targetPath.substring(home.path.length) + : ''; + final memberPath = relativePath.endsWith('/') + ? relativePath.substring(0, relativePath.length - 1) + : relativePath; + if (!profile.isTrustedCredentialDestination( + target, + accountAuthority: authority, + ) || + target.query.isNotEmpty || + target.fragment.isNotEmpty || + !target.path.endsWith('/') || + relativePath.isEmpty || + memberPath.isEmpty || + memberPath.contains('/')) { + throw _invalidCalendarMutationContext(); + } +} + +String _requiredCalendarTitle(String title) { + final value = title.trim(); + if (value.isEmpty) { + throw ArgumentError.value(title, 'title', 'A calendar name is required.'); + } + return value; +} + +String _eventCalendarMkcalendarXml(String displayName) => + '' + '' + '' + '${escapeDavXmlText(displayName)}' + '' + '' + '' + '' + ''; + +const _eventCalendarProbeXml = + '' + '' + '' + '' + ''; + +bool _calendarCreationMayHaveCommitted(DavException error) => + switch (error.kind) { + DavErrorKind.timeout || + DavErrorKind.network || + DavErrorKind.server => true, + _ => false, + }; + +DavException _calendarCreationStatusError( + int statusCode, { + required String correlationId, +}) { + final mapped = switch (statusCode) { + HttpStatus.unauthorized => ( + DavErrorKind.authentication, + 'DavAuthRejected', + 'Nextcloud rejected the account credential.', + ), + HttpStatus.forbidden => ( + DavErrorKind.authorization, + 'DavCalendarParentNotWritable', + 'Nextcloud did not allow calendar creation.', + ), + HttpStatus.conflict || HttpStatus.preconditionFailed => ( + DavErrorKind.conflict, + 'DavCalendarCollectionConflict', + 'A Nextcloud calendar already exists at the selected location.', + ), + HttpStatus.methodNotAllowed || HttpStatus.notImplemented => ( + DavErrorKind.unsupportedComponent, + 'DavMkcalendarUnsupported', + 'This Nextcloud server does not support calendar creation.', + ), + HttpStatus.tooManyRequests => ( + DavErrorKind.rateLimited, + 'DavRateLimited', + 'Nextcloud temporarily limited calendar creation.', + ), + >= 500 => ( + DavErrorKind.server, + 'DavServerUnavailable', + 'Nextcloud could not complete calendar creation.', + ), + _ => ( + DavErrorKind.protocol, + 'DavCalendarCollectionCreationRejected', + 'Nextcloud rejected calendar creation.', + ), + }; + return DavException( + kind: mapped.$1, + code: mapped.$2, + safeMessage: mapped.$3, + statusCode: statusCode, + correlationId: correlationId, + ); +} + +DavException _invalidCalendarMutationContext() => const DavException( + kind: DavErrorKind.protocol, + code: 'DavCalendarMutationContextInvalid', + safeMessage: 'The Nextcloud calendar connection is invalid.', +); + +Future _noNetworkCheck() async {} diff --git a/lib/src/dav/mutation/dav_collection_mutation_helpers.dart b/lib/src/dav/mutation/dav_collection_mutation_helpers.dart new file mode 100644 index 00000000..d2a6a651 --- /dev/null +++ b/lib/src/dav/mutation/dav_collection_mutation_helpers.dart @@ -0,0 +1,43 @@ +Uri davCollectionUri(Uri uri) { + final path = uri.path.endsWith('/') ? uri.path : '${uri.path}/'; + return uri.replace(path: path, query: null, fragment: null); +} + +String nextcloudCollectionMemberName( + String displayName, { + required Uri homeUri, + required Iterable existingCollectionUris, +}) { + var candidate = displayName + .toLowerCase() + .replaceAll(RegExp(r'\s+'), '-') + .replaceAll(RegExp(r'[^\w-]+'), '') + .replaceAll(RegExp(r'--+'), '-') + .replaceFirst(RegExp(r'^-+'), '') + .replaceFirst(RegExp(r'-+$'), ''); + if (candidate.isEmpty) candidate = '-'; + final home = davCollectionUri(homeUri); + final occupied = { + for (final uri in existingCollectionUris) davCollectionUri(uri).toString(), + }; + bool available(String value) => + !occupied.contains(davCollectionUri(home.resolve(value)).toString()); + if (available(candidate)) return candidate; + if (!candidate.contains('-')) { + candidate = '$candidate-1'; + if (available(candidate)) return candidate; + } + do { + final lastDash = candidate.lastIndexOf('-'); + final first = candidate.substring(0, lastDash); + final suffix = candidate.substring(lastDash + 1); + final number = int.tryParse(suffix); + candidate = number == null ? '$candidate-1' : '$first-${number + 1}'; + } while (!available(candidate)); + return candidate; +} + +String escapeDavXmlText(String value) => value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>'); diff --git a/lib/src/dav/mutation/dav_mutation_patch.dart b/lib/src/dav/mutation/dav_mutation_patch.dart index 57f54689..48c9c3b3 100644 --- a/lib/src/dav/mutation/dav_mutation_patch.dart +++ b/lib/src/dav/mutation/dav_mutation_patch.dart @@ -516,6 +516,7 @@ const _editableProperties = { 'COMPLETED', 'CATEGORIES', 'RELATED-TO', + 'RECURRENCE-ID', 'RRULE', 'RDATE', 'EXDATE', diff --git a/lib/src/dav/mutation/dav_pending_operations.dart b/lib/src/dav/mutation/dav_pending_operations.dart index b4bf855f..cba7d6f3 100644 --- a/lib/src/dav/mutation/dav_pending_operations.dart +++ b/lib/src/dav/mutation/dav_pending_operations.dart @@ -4,6 +4,7 @@ import 'dart:math'; import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; +import '../../calendar_providers/calendar_mutation.dart'; import '../../db/app_database.dart'; import '../../features/accounts/domain/account_connection_state.dart'; import '../../providers/busy_provider.dart'; @@ -393,8 +394,11 @@ final class DavPendingOperationQueue { objectId, ); final destination = await _context(accountId, destinationCollectionId); - if (source.provider != BusyProvider.nextcloud || - destination.provider != BusyProvider.nextcloud || + final providerSupportsMove = + source.provider == BusyProvider.nextcloud || + source.provider == BusyProvider.appleICloud; + if (!providerSupportsMove || + destination.provider != source.provider || source.collection.id == destination.collection.id) { throw _invalidPendingOperation(); } @@ -732,6 +736,17 @@ final class DavPendingOperationsReplayer { if (op.dependsOnOpId != null && await _opExists(op.dependsOnOpId!)) { continue; } + if (_copyConfirmationMissing(op)) { + final error = const DavException( + kind: DavErrorKind.protocol, + code: 'DavEventCopyNotConfirmed', + safeMessage: + 'The destination event was not confirmed, so the original was kept.', + ); + await _markFailed(op, error); + await _reportPermanentFailure(op, error); + continue; + } try { await _markInProgress(op); final result = await _replay(op); @@ -920,6 +935,7 @@ final class DavPendingOperationsReplayer { completedAtUtc: _nowUtc(), ); } + await _confirmDependentCopyDeletes(op, canonical?.hrefKey); await _database.pendingOpsDao.deleteOp(op.id); if (op.operationType == 'dav.create') { if (op.eventId != null) { @@ -945,6 +961,30 @@ final class DavPendingOperationsReplayer { return affected; } + Future _confirmDependentCopyDeletes( + PendingOp completedCreate, + String? destinationHref, + ) async { + if (completedCreate.operationType != 'dav.create' || + completedCreate.eventId == null) { + return; + } + final dependents = await (_database.select( + _database.pendingOps, + )..where((row) => row.dependsOnOpId.equals(completedCreate.id))).get(); + for (final dependent in dependents) { + final request = _requestObject(dependent); + if (request[calendarEventCopyConfirmationRequiredKey] != true) continue; + request[calendarEventCopyConfirmedKey] = true; + if (destinationHref != null) { + request[calendarEventCopyDestinationEventIdKey] = destinationHref; + } + await (_database.update(_database.pendingOps) + ..where((row) => row.id.equals(dependent.id))) + .write(PendingOpsCompanion(requestJson: Value(jsonEncode(request)))); + } + } + Future _recordConflict(PendingOp op, DavMutationResult result) async { final analysis = result.conflict; if (analysis == null) throw _invalidPendingOperation(); @@ -1128,6 +1168,19 @@ final class DavPendingOperationsReplayer { Future _opExists(String id) async => await _database.pendingOpsDao.getOp(id) != null; + bool _copyConfirmationMissing(PendingOp op) { + final request = _requestObject(op); + return request[calendarEventCopyConfirmationRequiredKey] == true && + request[calendarEventCopyConfirmedKey] != true; + } + + Map _requestObject(PendingOp op) { + final decoded = jsonDecode(op.requestJson); + return decoded is Map + ? Map.from(decoded) + : {}; + } + Future _requiredCollection(PendingOp op) async { final id = _required(op.davCollectionId); final collection = await (_database.select( diff --git a/lib/src/dav/mutation/dav_projection_mutations.dart b/lib/src/dav/mutation/dav_projection_mutations.dart index b8c6ed2b..51e8f1d8 100644 --- a/lib/src/dav/mutation/dav_projection_mutations.dart +++ b/lib/src/dav/mutation/dav_projection_mutations.dart @@ -19,6 +19,7 @@ final class DavEventMutationInput { this.recurrence, this.recurrenceChanged = false, this.reminders, + this.remindersChanged = false, this.categories = const [], this.categoriesChanged = false, this.classification, @@ -36,6 +37,7 @@ final class DavEventMutationInput { final Object? recurrence; final bool recurrenceChanged; final Object? reminders; + final bool remindersChanged; final List categories; final bool categoriesChanged; final String? classification; @@ -75,7 +77,7 @@ DavNewObject buildDavEventObject( if (input.categories.isNotEmpty) DavPatchOperation.setRaw('CATEGORIES', _categories(input.categories)), ..._recurrenceOperations(input.recurrence), - ..._alarmOperations(reminderMinutes(input.reminders), startIndex: 0), + ..._eventAlarmOperations(input.reminders, startIndex: 0), ]; if (operations.isEmpty) return initial; final patch = DavMutationPatch( @@ -169,31 +171,8 @@ DavMutationPatch? buildDavEventUpdatePatch({ if (input.recurrenceChanged) { operations.addAll(_recurrenceOperations(input.recurrence)); } - final desiredReminders = reminderMinutes(input.reminders); - final editableAlarm = _editableDisplayAlarm(current); - if (!_sameIntegers(editableAlarm.minutes, desiredReminders)) { - if (editableAlarm.index != null) { - operations.add( - DavPatchOperation.replaceAlarm( - alarmIndex: editableAlarm.index!, - alarm: desiredReminders.isEmpty - ? null - : _displayAlarm(desiredReminders.first), - ), - ); - for (var index = 1; index < desiredReminders.length; index += 1) { - operations.add( - DavPatchOperation.replaceAlarm( - alarmIndex: current.alarms.length + index - 1, - alarm: _displayAlarm(desiredReminders[index]), - ), - ); - } - } else { - operations.addAll( - _alarmOperations(desiredReminders, startIndex: current.alarms.length), - ); - } + if (input.remindersChanged) { + operations.addAll(_eventAlarmUpdateOperations(current, input.reminders)); } if (operations.isEmpty) return null; return DavMutationPatch( @@ -213,6 +192,7 @@ DavMutationPatch buildDavEventOccurrenceExceptionPatch({ required String occurrenceKey, required String baselineRawIcs, required DavEventMutationInput input, + bool thisAndFuture = false, DateTime Function()? nowUtc, }) { final semantic = IcalSemanticDocument.parse(baselineRawIcs); @@ -224,14 +204,45 @@ DavMutationPatch buildDavEventOccurrenceExceptionPatch({ ); if (master.length != 1) throw _invalidMutation(); final recurrence = _recurrenceIdentity(occurrenceKey); - if (semantic.components.any( + final existing = semantic.components.where( (component) => component.componentType == 'VEVENT' && component.uid == uid && - component.recurrenceIdKey == recurrence.key, - )) { + _recurrenceValueKey(component) == recurrence.key, + ); + if (existing.length > 1 || (existing.isNotEmpty && !thisAndFuture)) { throw _invalidMutation(); } + if (existing.isNotEmpty) { + final current = existing.single; + final target = IcalComponentKey( + componentType: 'VEVENT', + uid: uid, + recurrenceIdKey: current.recurrenceIdKey, + ); + final update = buildDavEventUpdatePatch( + target: target, + baselineRawIcs: baselineRawIcs, + input: input, + ); + final recurrenceProperty = current.documentComponent.firstProperty( + 'RECURRENCE-ID', + ); + if (recurrenceProperty == null) throw _invalidMutation(); + return DavMutationPatch( + target: target, + scope: DavMutationScope.occurrence, + operations: [ + ...?update?.operations, + if (current.recurrenceRange != 'THISANDFUTURE') + DavPatchOperation.setRaw( + 'RECURRENCE-ID', + recurrenceProperty.rawValue, + parameters: _withThisAndFuture(recurrenceProperty.parameters), + ), + ], + ); + } final timestamp = (nowUtc ?? (() => DateTime.now().toUtc()))().toUtc(); final start = _eventTemporal( input.start, @@ -251,7 +262,9 @@ DavMutationPatch buildDavEventOccurrenceExceptionPatch({ _property( 'RECURRENCE-ID', recurrence.raw, - parameters: recurrence.parameters, + parameters: thisAndFuture + ? _withThisAndFuture(recurrence.parameters) + : recurrence.parameters, ), _property('DTSTAMP', _utcIcal(timestamp)), if (sequence != null) _property('SEQUENCE', '${sequence + 1}'), @@ -268,8 +281,7 @@ DavMutationPatch buildDavEventOccurrenceExceptionPatch({ _property('TRANSP', value), if (input.categories.isNotEmpty) _property('CATEGORIES', _categories(input.categories)), - for (final minutes in reminderMinutes(input.reminders)) - _displayAlarm(minutes), + ..._eventAlarmComponents(input.reminders), ], originalBeginLine: 'BEGIN:VEVENT', originalEndLine: 'END:VEVENT', @@ -293,6 +305,7 @@ DavMutationPatch buildDavEventOccurrenceCancellationPatch({ required String uid, required String occurrenceKey, required String baselineRawIcs, + bool thisAndFuture = false, DateTime Function()? nowUtc, }) { final semantic = IcalSemanticDocument.parse(baselineRawIcs); @@ -301,7 +314,7 @@ DavMutationPatch buildDavEventOccurrenceCancellationPatch({ (component) => component.componentType == 'VEVENT' && component.uid == uid && - component.recurrenceIdKey == recurrence.key, + _recurrenceValueKey(component) == recurrence.key, ); final target = IcalComponentKey( componentType: 'VEVENT', @@ -310,10 +323,27 @@ DavMutationPatch buildDavEventOccurrenceCancellationPatch({ ); if (existing.length > 1) throw _invalidMutation(); if (existing.length == 1) { + final component = existing.single; + final recurrenceProperty = component.documentComponent.firstProperty( + 'RECURRENCE-ID', + ); + if (recurrenceProperty == null) throw _invalidMutation(); return DavMutationPatch( - target: target, + target: IcalComponentKey( + componentType: 'VEVENT', + uid: uid, + recurrenceIdKey: component.recurrenceIdKey, + ), scope: DavMutationScope.occurrence, - operations: [DavPatchOperation.setRaw('STATUS', 'CANCELLED')], + operations: [ + DavPatchOperation.setRaw('STATUS', 'CANCELLED'), + if (thisAndFuture && component.recurrenceRange != 'THISANDFUTURE') + DavPatchOperation.setRaw( + 'RECURRENCE-ID', + recurrenceProperty.rawValue, + parameters: _withThisAndFuture(recurrenceProperty.parameters), + ), + ], ); } final master = semantic.components.where( @@ -332,7 +362,9 @@ DavMutationPatch buildDavEventOccurrenceCancellationPatch({ _property( 'RECURRENCE-ID', recurrence.raw, - parameters: recurrence.parameters, + parameters: thisAndFuture + ? _withThisAndFuture(recurrence.parameters) + : recurrence.parameters, ), _property('DTSTAMP', _utcIcal(timestamp)), if (sequence != null) _property('SEQUENCE', '${sequence + 1}'), @@ -826,6 +858,9 @@ List reminderMinutes(Object? reminders) { } } } + if (map['minutes'] is List) { + values.addAll((map['minutes']! as List).whereType()); + } return values.where((value) => value >= 0).toSet().toList()..sort(); } @@ -981,16 +1016,109 @@ String _graphRecurrenceToRrule(Map recurrence) { return parts.join(';'); } -List _alarmOperations( - List minutes, { +List _eventAlarmOperations( + Object? reminders, { required int startIndex, -}) => [ - for (var index = 0; index < minutes.length; index += 1) - DavPatchOperation.replaceAlarm( - alarmIndex: startIndex + index, - alarm: _displayAlarm(minutes[index]), - ), -]; +}) { + final alarms = _eventAlarmComponents(reminders); + return [ + for (var index = 0; index < alarms.length; index += 1) + DavPatchOperation.replaceAlarm( + alarmIndex: startIndex + index, + alarm: alarms[index], + ), + ]; +} + +List _eventAlarmUpdateOperations( + IcalSemanticComponent current, + Object? reminders, +) { + final projected = _projectedEventAlarms(reminders); + if (projected != null) { + return [ + for ( + var index = 0; + index < current.alarms.length && index < projected.length; + index += 1 + ) + DavPatchOperation.replaceAlarm( + alarmIndex: index, + alarm: projected[index], + ), + for ( + var index = current.alarms.length - 1; + index >= projected.length; + index -= 1 + ) + DavPatchOperation.replaceAlarm(alarmIndex: index, alarm: null), + for ( + var index = current.alarms.length; + index < projected.length; + index += 1 + ) + DavPatchOperation.replaceAlarm( + alarmIndex: index, + alarm: projected[index], + ), + ]; + } + + final desiredReminders = reminderMinutes(reminders); + final editableAlarms = _editableDisplayAlarms(current); + final currentReminders = [for (final alarm in editableAlarms) alarm.minutes]; + if (_sameIntegers(currentReminders, desiredReminders)) return const []; + return [ + for ( + var index = 0; + index < editableAlarms.length && index < desiredReminders.length; + index += 1 + ) + DavPatchOperation.replaceAlarm( + alarmIndex: editableAlarms[index].index, + alarm: _displayAlarm(desiredReminders[index]), + ), + for ( + var index = editableAlarms.length - 1; + index >= desiredReminders.length; + index -= 1 + ) + DavPatchOperation.replaceAlarm( + alarmIndex: editableAlarms[index].index, + alarm: null, + ), + for ( + var index = editableAlarms.length; + index < desiredReminders.length; + index += 1 + ) + DavPatchOperation.replaceAlarm( + alarmIndex: current.alarms.length + index - editableAlarms.length, + alarm: _displayAlarm(desiredReminders[index]), + ), + ]; +} + +List _eventAlarmComponents(Object? reminders) { + return _projectedEventAlarms(reminders) ?? + [ + for (final minutes in reminderMinutes(reminders)) + _displayAlarm(minutes), + ]; +} + +List? _projectedEventAlarms(Object? reminders) { + if (reminders is! Map || !reminders.containsKey('alarms')) return null; + final values = reminders['alarms']; + if (values is! List) throw _invalidMutation(); + return [ + for (final value in values) + if (value is Map) + IcalTaskAlarm.fromJson(value.cast()).toComponent() + else + throw _invalidMutation(), + ]; +} IcalComponent _displayAlarm(int minutes) => IcalComponent( name: 'VALARM', @@ -1004,9 +1132,10 @@ IcalComponent _displayAlarm(int minutes) => IcalComponent( structurallyDirty: true, ); -({int? index, List minutes}) _editableDisplayAlarm( +List<({int index, int minutes})> _editableDisplayAlarms( IcalSemanticComponent component, ) { + final result = <({int index, int minutes})>[]; for (var index = 0; index < component.alarms.length; index += 1) { final alarm = component.alarms[index]; if (alarm.firstProperty('ACTION')?.rawValue.toUpperCase() != 'DISPLAY') { @@ -1017,10 +1146,10 @@ IcalComponent _displayAlarm(int minutes) => IcalComponent( ? null : RegExp(r'^-PT([0-9]+)M$').firstMatch(trigger); if (match != null) { - return (index: index, minutes: [int.parse(match.group(1)!)]); + result.add((index: index, minutes: int.parse(match.group(1)!))); } } - return (index: null, minutes: const []); + return result; } ({int? index, List minutes}) _editableTaskDisplayAlarm( @@ -1513,6 +1642,33 @@ _RecurrenceIdentity _recurrenceIdentity(String occurrenceKey) { return (raw: raw, parameters: parameters, key: key); } +String? _recurrenceValueKey(IcalSemanticComponent component) { + final property = component.documentComponent.firstProperty('RECURRENCE-ID'); + if (property == null) return null; + return icalRecurrenceIdKey( + IcalProperty( + group: property.group, + name: property.name, + parameters: [ + for (final parameter in property.parameters) + if (parameter.name.toUpperCase() != 'RANGE') parameter, + ], + rawValue: property.rawValue, + originalPhysicalLines: const [], + ), + ); +} + +List _withThisAndFuture(List parameters) => [ + for (final parameter in parameters) + if (parameter.name.toUpperCase() != 'RANGE') parameter, + const IcalParameter( + name: 'RANGE', + values: ['THISANDFUTURE'], + wasQuoted: false, + ), +]; + typedef _RecurrenceIdentity = ({ String raw, List parameters, diff --git a/lib/src/dav/mutation/dav_task_list_mutation_service.dart b/lib/src/dav/mutation/dav_task_list_mutation_service.dart index e67faa14..b5b8d469 100644 --- a/lib/src/dav/mutation/dav_task_list_mutation_service.dart +++ b/lib/src/dav/mutation/dav_task_list_mutation_service.dart @@ -13,6 +13,10 @@ import '../discovery/dav_discovery_models.dart'; import '../http/dav_http_transport.dart'; import '../storage/dav_collection_capabilities.dart'; import '../xml/dav_xml.dart'; +import 'dav_collection_mutation_helpers.dart'; + +export 'dav_collection_mutation_helpers.dart' + show nextcloudCollectionMemberName; const nextcloudDefaultTaskListColor = '#0082C9'; @@ -34,6 +38,7 @@ final class DavTaskListMutationService implements DavTaskListMutationClient { required http.Client httpClient, required String accountId, required Future Function() refreshAfterMutation, + Future Function()? requireNetwork, DavTransportLimits transportLimits = const DavTransportLimits(), DavXmlParser xmlParser = const DavXmlParser(), String Function()? correlationIdFactory, @@ -42,6 +47,7 @@ final class DavTaskListMutationService implements DavTaskListMutationClient { _httpClient = httpClient, _accountId = accountId, _refreshAfterMutation = refreshAfterMutation, + _requireNetwork = requireNetwork ?? _noNetworkCheck, _transportLimits = transportLimits, _xmlParser = xmlParser, _correlationIdFactory = correlationIdFactory ?? const Uuid().v4; @@ -51,6 +57,7 @@ final class DavTaskListMutationService implements DavTaskListMutationClient { final http.Client _httpClient; final String _accountId; final Future Function() _refreshAfterMutation; + final Future Function() _requireNetwork; final DavTransportLimits _transportLimits; final DavXmlParser _xmlParser; final String Function() _correlationIdFactory; @@ -58,6 +65,7 @@ final class DavTaskListMutationService implements DavTaskListMutationClient { @override Future createTaskList(String title) async { final displayName = _requiredTitle(title); + await _requireNetwork(); final context = await _loadContext(requireCalendarHome: true); final existing = await _activeCollections(); if (existing.any( @@ -72,7 +80,7 @@ final class DavTaskListMutationService implements DavTaskListMutationClient { ); } - final homeUri = _collectionUri(context.calendarHomeUri!); + final homeUri = davCollectionUri(context.calendarHomeUri!); final memberName = nextcloudCollectionMemberName( displayName, homeUri: homeUri, @@ -387,40 +395,6 @@ final class DavTaskListMutationService implements DavTaskListMutationClient { } } -String nextcloudCollectionMemberName( - String displayName, { - required Uri homeUri, - required Iterable existingCollectionUris, -}) { - var candidate = displayName - .toLowerCase() - .replaceAll(RegExp(r'\s+'), '-') - .replaceAll(RegExp(r'[^\w-]+'), '') - .replaceAll(RegExp(r'--+'), '-') - .replaceFirst(RegExp(r'^-+'), '') - .replaceFirst(RegExp(r'-+$'), ''); - if (candidate.isEmpty) candidate = '-'; - final home = _collectionUri(homeUri); - final occupied = { - for (final uri in existingCollectionUris) _collectionUri(uri).toString(), - }; - bool available(String value) => - !occupied.contains(_collectionUri(home.resolve(value)).toString()); - if (available(candidate)) return candidate; - if (!candidate.contains('-')) { - candidate = '$candidate-1'; - if (available(candidate)) return candidate; - } - do { - final lastDash = candidate.lastIndexOf('-'); - final first = candidate.substring(0, lastDash); - final suffix = candidate.substring(lastDash + 1); - final number = int.tryParse(suffix); - candidate = number == null ? '$candidate-1' : '$first-${number + 1}'; - } while (!available(candidate)); - return candidate; -} - final class _DavTaskListContext { const _DavTaskListContext({ required this.transport, @@ -433,11 +407,6 @@ final class _DavTaskListContext { final Uri? calendarHomeUri; } -Uri _collectionUri(Uri uri) { - final path = uri.path.endsWith('/') ? uri.path : '${uri.path}/'; - return uri.replace(path: path, query: null, fragment: null); -} - String _requiredTitle(String title) { final value = title.trim(); if (value.isEmpty) { @@ -446,11 +415,6 @@ String _requiredTitle(String title) { return value; } -String _xmlText(String value) => value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>'); - String _taskCollectionMkcolXml({ required String displayName, required String color, @@ -462,8 +426,8 @@ String _taskCollectionMkcolXml({ 'xmlns:o="$owncloudNamespace">' '' '' - '${_xmlText(displayName)}' - '${_xmlText(color)}' + '${escapeDavXmlText(displayName)}' + '${escapeDavXmlText(color)}' '1' '' '' @@ -475,7 +439,7 @@ String _displayNameProppatchXml(String displayName) => '' '' '' - '${_xmlText(displayName)}' + '${escapeDavXmlText(displayName)}' '' ''; @@ -573,3 +537,5 @@ String? _normalizedHrefPath(String? value) { } return path; } + +Future _noNetworkCheck() async {} diff --git a/lib/src/dav/storage/dav_object_repository.dart b/lib/src/dav/storage/dav_object_repository.dart index 9ad74909..37f9c7c2 100644 --- a/lib/src/dav/storage/dav_object_repository.dart +++ b/lib/src/dav/storage/dav_object_repository.dart @@ -5,6 +5,7 @@ import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; import '../../db/app_database.dart'; +import '../../ical/ical_event_projection.dart'; import '../../providers/busy_provider.dart'; import '../dav_errors.dart'; import '../ical/ical_document.dart'; @@ -892,69 +893,27 @@ final class DavObjectRepository { safeMessage: 'The calendar projection source was missing.', ); } - final occurrences = _recurrenceExpander.expand( - semantic, - rangeStartUtc: commit.projectionRangeStartUtc, - rangeEndUtc: commit.projectionRangeEndUtc, - ); - final timeZoneResolver = IcalTimeZoneResolver.fromDocument(semantic); - final recurringUids = { - for (final component in semantic.components) - if (component.componentType == 'VEVENT' && - (component.recurrenceId != null || - component.recurrenceRules.isNotEmpty || - component.recurrenceDates.isNotEmpty || - component.exceptionDates.isNotEmpty)) - component.uid, - }; + final projections = + IcalEventProjector( + recurrenceExpander: _recurrenceExpander, + ).projectSemantic( + semantic, + rangeStartUtc: commit.projectionRangeStartUtc, + rangeEndUtc: commit.projectionRangeEndUtc, + transport: 'caldav', + ); final now = commit.completedAtUtc.toUtc().millisecondsSinceEpoch; - for (final occurrence in occurrences) { - final component = occurrence.effectiveComponent; + for (final projected in projections) { final componentId = componentIds[_componentKey( - component.componentType, - component.uid!, - component.recurrenceIdKey, + 'VEVENT', + projected.uid, + projected.recurrenceIdKey, )]!; final eventId = _stableProjectionId( 'dav-event', - '$objectId\u0000${occurrence.occurrenceKey}', + '$objectId\u0000${projected.occurrenceKey}', ); - final allDay = occurrence.start.kind == IcalTemporalKind.date; - final recurrence = { - 'rules': occurrence.master.recurrenceRules, - 'dates': occurrence.master.recurrenceDates, - 'excludedDates': occurrence.master.exceptionDates, - }; - final attendees = component.attendees.isEmpty - ? occurrence.master.attendees - : component.attendees; - final organizers = component.organizers.isEmpty - ? occurrence.master.organizers - : component.organizers; - final categories = component.categories.isEmpty - ? occurrence.master.categories - : component.categories; - final alarms = component.alarms.isEmpty - ? occurrence.master.alarms - : component.alarms; - final startUtc = _resolvedTemporalUtc(occurrence.start, timeZoneResolver); - final endUtc = occurrence.end == null - ? null - : _resolvedTemporalUtc(occurrence.end!, timeZoneResolver); - final projectionJson = jsonEncode({ - 'transport': 'caldav', - 'uid': component.uid, - 'occurrenceKey': occurrence.occurrenceKey, - 'nativeStart': _temporalJson(occurrence.start), - if (occurrence.end != null) 'nativeEnd': _temporalJson(occurrence.end!), - if (startUtc != null) 'startUtc': startUtc.toIso8601String(), - if (endUtc != null) 'endUtc': endUtc.toIso8601String(), - 'extensionProperties': { - ...occurrence.master.extensionProperties, - ...component.extensionProperties, - }, - }); await _database .into(_database.calendarEvents) .insertOnConflictUpdate( @@ -968,93 +927,47 @@ final class DavObjectRepository { davCollectionId: Value(commit.collectionId), davObjectId: Value(objectId), davComponentId: Value(componentId), - icalUid: Value(component.uid), - recurrenceIdKey: Value(component.recurrenceIdKey), - occurrenceKey: Value(occurrence.occurrenceKey), + icalUid: Value(projected.uid), + recurrenceIdKey: Value(projected.recurrenceIdKey), + occurrenceKey: Value(projected.occurrenceKey), projectionVersion: const Value(davProjectionVersion), providerRecurringEventId: Value( - recurringUids.contains(occurrence.master.uid) - ? occurrence.master.uid - : null, + projected.recurring ? projected.uid : null, ), providerOriginalStartKey: Value( - recurringUids.contains(occurrence.master.uid) - ? occurrence.occurrenceKey - : null, + projected.recurring ? projected.occurrenceKey : null, ), etagOrChangeKey: Value(etag), - status: Value(component.status ?? occurrence.master.status), - title: occurrence.summary ?? '', - description: Value(occurrence.description), - location: Value(occurrence.location), - allDay: Value(allDay), - startDate: Value( - allDay ? _storageTemporal(occurrence.start) : null, - ), - startDateTime: Value( - allDay ? null : _storageTemporal(occurrence.start), - ), - startTimeZone: Value(_timeZoneName(occurrence.start)), - endDate: Value( - allDay && occurrence.end != null - ? _storageTemporal(occurrence.end!) - : null, - ), - endDateTime: Value( - !allDay && occurrence.end != null - ? _storageTemporal(occurrence.end!) - : null, - ), - endTimeZone: Value( - occurrence.end == null ? null : _timeZoneName(occurrence.end!), - ), - recurrenceJson: Value(jsonEncode(recurrence)), - remindersJson: Value( - jsonEncode({ - 'minutes': _eventReminderMinutes(alarms), - 'alarms': _alarmProjection(alarms), - }), - ), - attendeesJson: Value(jsonEncode(attendees)), - categoriesJson: Value(jsonEncode(categories)), - organizerJson: Value( - organizers.isEmpty ? null : jsonEncode(organizers.first), - ), + status: Value(projected.status), + title: projected.title, + description: Value(projected.description), + location: Value(projected.location), + allDay: Value(projected.allDay), + startDate: Value(projected.startDate), + startDateTime: Value(projected.startDateTime), + startTimeZone: Value(projected.startTimeZone), + endDate: Value(projected.endDate), + endDateTime: Value(projected.endDateTime), + endTimeZone: Value(projected.endTimeZone), + recurrenceJson: Value(projected.recurrenceJson), + remindersJson: Value(projected.remindersJson), + attendeesJson: Value(projected.attendeesJson), + categoriesJson: Value(projected.categoriesJson), + organizerJson: Value(projected.organizerJson), colorHex: Value(collection.color), - visibility: Value( - component.classification ?? occurrence.master.classification, - ), - transparencyOrShowAs: Value( - component.transparency ?? occurrence.master.transparency, - ), - webLink: Value( - _propertyRaw(component, 'URL') ?? - _propertyRaw(occurrence.master, 'URL'), - ), - attachmentsJson: Value( - jsonEncode( - _propertyRawValues(component, 'ATTACH').isEmpty - ? _propertyRawValues(occurrence.master, 'ATTACH') - : _propertyRawValues(component, 'ATTACH'), - ), - ), - isCancelled: Value(occurrence.isCancelled), + visibility: Value(projected.visibility), + transparencyOrShowAs: Value(projected.transparency), + webLink: Value(projected.webLink), + attachmentsJson: Value(projected.attachmentsJson), + isCancelled: Value(projected.cancelled), isDeleted: const Value(false), - rawJson: Value(projectionJson), - createdAtServer: Value( - _storageTemporalNullable( - component.created ?? occurrence.master.created, - ), - ), - updatedAtServer: Value( - _storageTemporalNullable( - component.lastModified ?? occurrence.master.lastModified, - ), - ), + rawJson: Value(projected.rawJson), + createdAtServer: Value(projected.createdAtServer), + updatedAtServer: Value(projected.updatedAtServer), createdAtLocal: now, updatedAtLocal: now, syncStatus: const Value('synced'), - baselineRawJson: Value(projectionJson), + baselineRawJson: Value(projected.rawJson), ), ); } @@ -1397,32 +1310,6 @@ List> _alarmProjection(List alarms) => [ }, ]; -List _eventReminderMinutes(List alarms) { - final result = []; - for (final alarm in alarms) { - final action = alarm.firstProperty('ACTION')?.rawValue.toUpperCase(); - if (action != 'DISPLAY' && action != 'AUDIO') { - continue; - } - final trigger = alarm.firstProperty('TRIGGER'); - if (trigger == null || - trigger.parameterValue('RELATED')?.toUpperCase() == 'END') { - continue; - } - try { - final duration = parseIcalDuration(trigger.rawValue.toUpperCase()); - if (duration == null || !duration.negative) continue; - final absolute = -duration.duration; - if (absolute.inSeconds <= 0 || absolute.inSeconds % 60 != 0) continue; - final minutes = absolute.inMinutes; - if (!result.contains(minutes)) result.add(minutes); - } on DavException { - // Absolute and unsupported trigger forms remain in the raw alarm list. - } - } - return result; -} - ({String dateTime, String? timeZone})? _taskReminderProjection( List alarms, { required IcalTemporalValue? start, @@ -1492,15 +1379,6 @@ DateTime? _resolvedTemporalUtc( }; } -String? _propertyRaw(IcalSemanticComponent component, String name) => - component.documentComponent.firstProperty(name)?.rawValue; - -List _propertyRawValues(IcalSemanticComponent component, String name) => - component.documentComponent - .propertiesNamed(name) - .map((property) => property.rawValue) - .toList(growable: false); - bool _extensionFlag(Map> extensions, String name) { final value = extensions[name]?.firstOrNull?.trim().toUpperCase(); return value == 'TRUE' || value == '1'; diff --git a/lib/src/dav/sync/dav_account_sync_engine.dart b/lib/src/dav/sync/dav_account_sync_engine.dart index 84b10218..e220d858 100644 --- a/lib/src/dav/sync/dav_account_sync_engine.dart +++ b/lib/src/dav/sync/dav_account_sync_engine.dart @@ -174,6 +174,7 @@ final class DavAccountSyncEngine { final objectRepository = DavObjectRepository(database: _database); final discoveryRepository = DavDiscoveryRepository(database: _database); var discoveryRefreshed = false; + var notificationProjectionsChanged = false; final affected = {}; final failures = []; @@ -193,7 +194,8 @@ final class DavAccountSyncEngine { correlationId: _correlationIdFactory(), cancellationToken: cancellationToken, ); - await discoveryRepository.commitSuccessfulInventory(discovery); + notificationProjectionsChanged = await discoveryRepository + .commitSuccessfulInventory(discovery); } final collections = await _selectedCollections(); @@ -282,7 +284,7 @@ final class DavAccountSyncEngine { failures.addAll(followUpResults.failures); } - if (affected.isNotEmpty) { + if (affected.isNotEmpty || notificationProjectionsChanged) { await _rebuildNotifications?.call(_accountId, affected); } if (failures.isNotEmpty) { @@ -368,12 +370,10 @@ final class DavAccountSyncEngine { DavException _mapSecretStoreFailure(SecretStoreException error) { if (error.code == 'SecretStoreUnavailable') { - return const DavException( + return DavException( kind: DavErrorKind.network, code: 'DavCredentialStoreUnavailable', - safeMessage: - 'Secure credential storage is temporarily unavailable. Unlock ' - 'the system keyring and try again.', + safeMessage: error.message, ); } return const DavException( diff --git a/lib/src/db/app_database.dart b/lib/src/db/app_database.dart index 699efea7..4b3c9cb3 100644 --- a/lib/src/db/app_database.dart +++ b/lib/src/db/app_database.dart @@ -28,6 +28,8 @@ part 'daos/tasks_dao.dart'; SyncRuns, CalendarSources, CalendarEvents, + IcalImportReceipts, + WebCalSubscriptions, CalendarEventAttendees, CalendarEventReminders, SyncCursors, diff --git a/lib/src/db/app_database.g.dart b/lib/src/db/app_database.g.dart index 21dce602..df3cb91d 100644 --- a/lib/src/db/app_database.g.dart +++ b/lib/src/db/app_database.g.dart @@ -7013,6 +7013,21 @@ class $TaskListsTable extends TaskLists ), defaultValue: const Constant(false), ); + static const VerificationMeta _remindersEnabledMeta = const VerificationMeta( + 'remindersEnabled', + ); + @override + late final GeneratedColumn remindersEnabled = GeneratedColumn( + 'reminders_enabled', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("reminders_enabled" IN (0, 1))', + ), + defaultValue: const Constant(true), + ); static const VerificationMeta _lastSyncedAtUtcMeta = const VerificationMeta( 'lastSyncedAtUtc', ); @@ -7067,6 +7082,7 @@ class $TaskListsTable extends TaskLists serverMissing, localDirty, pendingDelete, + remindersEnabled, lastSyncedAtUtc, createdLocalAtUtc, updatedLocalAtUtc, @@ -7205,6 +7221,15 @@ class $TaskListsTable extends TaskLists ), ); } + if (data.containsKey('reminders_enabled')) { + context.handle( + _remindersEnabledMeta, + remindersEnabled.isAcceptableOrUnknown( + data['reminders_enabled']!, + _remindersEnabledMeta, + ), + ); + } if (data.containsKey('last_synced_at_utc')) { context.handle( _lastSyncedAtUtcMeta, @@ -7313,6 +7338,10 @@ class $TaskListsTable extends TaskLists DriftSqlType.bool, data['${effectivePrefix}pending_delete'], )!, + remindersEnabled: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}reminders_enabled'], + )!, lastSyncedAtUtc: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}last_synced_at_utc'], @@ -7352,6 +7381,7 @@ class TaskList extends DataClass implements Insertable { final bool serverMissing; final bool localDirty; final bool pendingDelete; + final bool remindersEnabled; final String? lastSyncedAtUtc; final String createdLocalAtUtc; final String updatedLocalAtUtc; @@ -7373,6 +7403,7 @@ class TaskList extends DataClass implements Insertable { required this.serverMissing, required this.localDirty, required this.pendingDelete, + required this.remindersEnabled, this.lastSyncedAtUtc, required this.createdLocalAtUtc, required this.updatedLocalAtUtc, @@ -7417,6 +7448,7 @@ class TaskList extends DataClass implements Insertable { map['server_missing'] = Variable(serverMissing); map['local_dirty'] = Variable(localDirty); map['pending_delete'] = Variable(pendingDelete); + map['reminders_enabled'] = Variable(remindersEnabled); if (!nullToAbsent || lastSyncedAtUtc != null) { map['last_synced_at_utc'] = Variable(lastSyncedAtUtc); } @@ -7460,6 +7492,7 @@ class TaskList extends DataClass implements Insertable { serverMissing: Value(serverMissing), localDirty: Value(localDirty), pendingDelete: Value(pendingDelete), + remindersEnabled: Value(remindersEnabled), lastSyncedAtUtc: lastSyncedAtUtc == null && nullToAbsent ? const Value.absent() : Value(lastSyncedAtUtc), @@ -7493,6 +7526,7 @@ class TaskList extends DataClass implements Insertable { serverMissing: serializer.fromJson(json['serverMissing']), localDirty: serializer.fromJson(json['localDirty']), pendingDelete: serializer.fromJson(json['pendingDelete']), + remindersEnabled: serializer.fromJson(json['remindersEnabled']), lastSyncedAtUtc: serializer.fromJson(json['lastSyncedAtUtc']), createdLocalAtUtc: serializer.fromJson(json['createdLocalAtUtc']), updatedLocalAtUtc: serializer.fromJson(json['updatedLocalAtUtc']), @@ -7519,6 +7553,7 @@ class TaskList extends DataClass implements Insertable { 'serverMissing': serializer.toJson(serverMissing), 'localDirty': serializer.toJson(localDirty), 'pendingDelete': serializer.toJson(pendingDelete), + 'remindersEnabled': serializer.toJson(remindersEnabled), 'lastSyncedAtUtc': serializer.toJson(lastSyncedAtUtc), 'createdLocalAtUtc': serializer.toJson(createdLocalAtUtc), 'updatedLocalAtUtc': serializer.toJson(updatedLocalAtUtc), @@ -7543,6 +7578,7 @@ class TaskList extends DataClass implements Insertable { bool? serverMissing, bool? localDirty, bool? pendingDelete, + bool? remindersEnabled, Value lastSyncedAtUtc = const Value.absent(), String? createdLocalAtUtc, String? updatedLocalAtUtc, @@ -7570,6 +7606,7 @@ class TaskList extends DataClass implements Insertable { serverMissing: serverMissing ?? this.serverMissing, localDirty: localDirty ?? this.localDirty, pendingDelete: pendingDelete ?? this.pendingDelete, + remindersEnabled: remindersEnabled ?? this.remindersEnabled, lastSyncedAtUtc: lastSyncedAtUtc.present ? lastSyncedAtUtc.value : this.lastSyncedAtUtc, @@ -7609,6 +7646,9 @@ class TaskList extends DataClass implements Insertable { pendingDelete: data.pendingDelete.present ? data.pendingDelete.value : this.pendingDelete, + remindersEnabled: data.remindersEnabled.present + ? data.remindersEnabled.value + : this.remindersEnabled, lastSyncedAtUtc: data.lastSyncedAtUtc.present ? data.lastSyncedAtUtc.value : this.lastSyncedAtUtc, @@ -7641,6 +7681,7 @@ class TaskList extends DataClass implements Insertable { ..write('serverMissing: $serverMissing, ') ..write('localDirty: $localDirty, ') ..write('pendingDelete: $pendingDelete, ') + ..write('remindersEnabled: $remindersEnabled, ') ..write('lastSyncedAtUtc: $lastSyncedAtUtc, ') ..write('createdLocalAtUtc: $createdLocalAtUtc, ') ..write('updatedLocalAtUtc: $updatedLocalAtUtc') @@ -7649,7 +7690,7 @@ class TaskList extends DataClass implements Insertable { } @override - int get hashCode => Object.hash( + int get hashCode => Object.hashAll([ accountId, id, davCollectionId, @@ -7667,10 +7708,11 @@ class TaskList extends DataClass implements Insertable { serverMissing, localDirty, pendingDelete, + remindersEnabled, lastSyncedAtUtc, createdLocalAtUtc, updatedLocalAtUtc, - ); + ]); @override bool operator ==(Object other) => identical(this, other) || @@ -7692,6 +7734,7 @@ class TaskList extends DataClass implements Insertable { other.serverMissing == this.serverMissing && other.localDirty == this.localDirty && other.pendingDelete == this.pendingDelete && + other.remindersEnabled == this.remindersEnabled && other.lastSyncedAtUtc == this.lastSyncedAtUtc && other.createdLocalAtUtc == this.createdLocalAtUtc && other.updatedLocalAtUtc == this.updatedLocalAtUtc); @@ -7715,6 +7758,7 @@ class TaskListsCompanion extends UpdateCompanion { final Value serverMissing; final Value localDirty; final Value pendingDelete; + final Value remindersEnabled; final Value lastSyncedAtUtc; final Value createdLocalAtUtc; final Value updatedLocalAtUtc; @@ -7737,6 +7781,7 @@ class TaskListsCompanion extends UpdateCompanion { this.serverMissing = const Value.absent(), this.localDirty = const Value.absent(), this.pendingDelete = const Value.absent(), + this.remindersEnabled = const Value.absent(), this.lastSyncedAtUtc = const Value.absent(), this.createdLocalAtUtc = const Value.absent(), this.updatedLocalAtUtc = const Value.absent(), @@ -7760,6 +7805,7 @@ class TaskListsCompanion extends UpdateCompanion { this.serverMissing = const Value.absent(), this.localDirty = const Value.absent(), this.pendingDelete = const Value.absent(), + this.remindersEnabled = const Value.absent(), this.lastSyncedAtUtc = const Value.absent(), required String createdLocalAtUtc, required String updatedLocalAtUtc, @@ -7788,6 +7834,7 @@ class TaskListsCompanion extends UpdateCompanion { Expression? serverMissing, Expression? localDirty, Expression? pendingDelete, + Expression? remindersEnabled, Expression? lastSyncedAtUtc, Expression? createdLocalAtUtc, Expression? updatedLocalAtUtc, @@ -7812,6 +7859,7 @@ class TaskListsCompanion extends UpdateCompanion { if (serverMissing != null) 'server_missing': serverMissing, if (localDirty != null) 'local_dirty': localDirty, if (pendingDelete != null) 'pending_delete': pendingDelete, + if (remindersEnabled != null) 'reminders_enabled': remindersEnabled, if (lastSyncedAtUtc != null) 'last_synced_at_utc': lastSyncedAtUtc, if (createdLocalAtUtc != null) 'created_local_at_utc': createdLocalAtUtc, if (updatedLocalAtUtc != null) 'updated_local_at_utc': updatedLocalAtUtc, @@ -7837,6 +7885,7 @@ class TaskListsCompanion extends UpdateCompanion { Value? serverMissing, Value? localDirty, Value? pendingDelete, + Value? remindersEnabled, Value? lastSyncedAtUtc, Value? createdLocalAtUtc, Value? updatedLocalAtUtc, @@ -7860,6 +7909,7 @@ class TaskListsCompanion extends UpdateCompanion { serverMissing: serverMissing ?? this.serverMissing, localDirty: localDirty ?? this.localDirty, pendingDelete: pendingDelete ?? this.pendingDelete, + remindersEnabled: remindersEnabled ?? this.remindersEnabled, lastSyncedAtUtc: lastSyncedAtUtc ?? this.lastSyncedAtUtc, createdLocalAtUtc: createdLocalAtUtc ?? this.createdLocalAtUtc, updatedLocalAtUtc: updatedLocalAtUtc ?? this.updatedLocalAtUtc, @@ -7923,6 +7973,9 @@ class TaskListsCompanion extends UpdateCompanion { if (pendingDelete.present) { map['pending_delete'] = Variable(pendingDelete.value); } + if (remindersEnabled.present) { + map['reminders_enabled'] = Variable(remindersEnabled.value); + } if (lastSyncedAtUtc.present) { map['last_synced_at_utc'] = Variable(lastSyncedAtUtc.value); } @@ -7958,6 +8011,7 @@ class TaskListsCompanion extends UpdateCompanion { ..write('serverMissing: $serverMissing, ') ..write('localDirty: $localDirty, ') ..write('pendingDelete: $pendingDelete, ') + ..write('remindersEnabled: $remindersEnabled, ') ..write('lastSyncedAtUtc: $lastSyncedAtUtc, ') ..write('createdLocalAtUtc: $createdLocalAtUtc, ') ..write('updatedLocalAtUtc: $updatedLocalAtUtc, ') @@ -14984,6 +15038,31 @@ class $CalendarSourcesTable extends CalendarSources type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _dataOwnerMeta = const VerificationMeta( + 'dataOwner', + ); + @override + late final GeneratedColumn dataOwner = GeneratedColumn( + 'data_owner', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _isRemovableMeta = const VerificationMeta( + 'isRemovable', + ); + @override + late final GeneratedColumn isRemovable = GeneratedColumn( + 'is_removable', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_removable" IN (0, 1))', + ), + ); static const VerificationMeta _isDeletedMeta = const VerificationMeta( 'isDeleted', ); @@ -15051,6 +15130,8 @@ class $CalendarSourcesTable extends CalendarSources colorId, timeZone, accessRole, + dataOwner, + isRemovable, isDeleted, rawJson, createdAtLocal, @@ -15198,6 +15279,21 @@ class $CalendarSourcesTable extends CalendarSources accessRole.isAcceptableOrUnknown(data['access_role']!, _accessRoleMeta), ); } + if (data.containsKey('data_owner')) { + context.handle( + _dataOwnerMeta, + dataOwner.isAcceptableOrUnknown(data['data_owner']!, _dataOwnerMeta), + ); + } + if (data.containsKey('is_removable')) { + context.handle( + _isRemovableMeta, + isRemovable.isAcceptableOrUnknown( + data['is_removable']!, + _isRemovableMeta, + ), + ); + } if (data.containsKey('is_deleted')) { context.handle( _isDeletedMeta, @@ -15309,6 +15405,14 @@ class $CalendarSourcesTable extends CalendarSources DriftSqlType.string, data['${effectivePrefix}access_role'], ), + dataOwner: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}data_owner'], + ), + isRemovable: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_removable'], + ), isDeleted: attachedDatabase.typeMapping.read( DriftSqlType.bool, data['${effectivePrefix}is_deleted'], @@ -15352,6 +15456,8 @@ class CalendarSource extends DataClass implements Insertable { final String? colorId; final String? timeZone; final String? accessRole; + final String? dataOwner; + final bool? isRemovable; final bool isDeleted; final String? rawJson; final int createdAtLocal; @@ -15374,6 +15480,8 @@ class CalendarSource extends DataClass implements Insertable { this.colorId, this.timeZone, this.accessRole, + this.dataOwner, + this.isRemovable, required this.isDeleted, this.rawJson, required this.createdAtLocal, @@ -15413,6 +15521,12 @@ class CalendarSource extends DataClass implements Insertable { if (!nullToAbsent || accessRole != null) { map['access_role'] = Variable(accessRole); } + if (!nullToAbsent || dataOwner != null) { + map['data_owner'] = Variable(dataOwner); + } + if (!nullToAbsent || isRemovable != null) { + map['is_removable'] = Variable(isRemovable); + } map['is_deleted'] = Variable(isDeleted); if (!nullToAbsent || rawJson != null) { map['raw_json'] = Variable(rawJson); @@ -15455,6 +15569,12 @@ class CalendarSource extends DataClass implements Insertable { accessRole: accessRole == null && nullToAbsent ? const Value.absent() : Value(accessRole), + dataOwner: dataOwner == null && nullToAbsent + ? const Value.absent() + : Value(dataOwner), + isRemovable: isRemovable == null && nullToAbsent + ? const Value.absent() + : Value(isRemovable), isDeleted: Value(isDeleted), rawJson: rawJson == null && nullToAbsent ? const Value.absent() @@ -15489,6 +15609,8 @@ class CalendarSource extends DataClass implements Insertable { colorId: serializer.fromJson(json['colorId']), timeZone: serializer.fromJson(json['timeZone']), accessRole: serializer.fromJson(json['accessRole']), + dataOwner: serializer.fromJson(json['dataOwner']), + isRemovable: serializer.fromJson(json['isRemovable']), isDeleted: serializer.fromJson(json['isDeleted']), rawJson: serializer.fromJson(json['rawJson']), createdAtLocal: serializer.fromJson(json['createdAtLocal']), @@ -15516,6 +15638,8 @@ class CalendarSource extends DataClass implements Insertable { 'colorId': serializer.toJson(colorId), 'timeZone': serializer.toJson(timeZone), 'accessRole': serializer.toJson(accessRole), + 'dataOwner': serializer.toJson(dataOwner), + 'isRemovable': serializer.toJson(isRemovable), 'isDeleted': serializer.toJson(isDeleted), 'rawJson': serializer.toJson(rawJson), 'createdAtLocal': serializer.toJson(createdAtLocal), @@ -15541,6 +15665,8 @@ class CalendarSource extends DataClass implements Insertable { Value colorId = const Value.absent(), Value timeZone = const Value.absent(), Value accessRole = const Value.absent(), + Value dataOwner = const Value.absent(), + Value isRemovable = const Value.absent(), bool? isDeleted, Value rawJson = const Value.absent(), int? createdAtLocal, @@ -15569,6 +15695,8 @@ class CalendarSource extends DataClass implements Insertable { colorId: colorId.present ? colorId.value : this.colorId, timeZone: timeZone.present ? timeZone.value : this.timeZone, accessRole: accessRole.present ? accessRole.value : this.accessRole, + dataOwner: dataOwner.present ? dataOwner.value : this.dataOwner, + isRemovable: isRemovable.present ? isRemovable.value : this.isRemovable, isDeleted: isDeleted ?? this.isDeleted, rawJson: rawJson.present ? rawJson.value : this.rawJson, createdAtLocal: createdAtLocal ?? this.createdAtLocal, @@ -15609,6 +15737,10 @@ class CalendarSource extends DataClass implements Insertable { accessRole: data.accessRole.present ? data.accessRole.value : this.accessRole, + dataOwner: data.dataOwner.present ? data.dataOwner.value : this.dataOwner, + isRemovable: data.isRemovable.present + ? data.isRemovable.value + : this.isRemovable, isDeleted: data.isDeleted.present ? data.isDeleted.value : this.isDeleted, rawJson: data.rawJson.present ? data.rawJson.value : this.rawJson, createdAtLocal: data.createdAtLocal.present @@ -15640,6 +15772,8 @@ class CalendarSource extends DataClass implements Insertable { ..write('colorId: $colorId, ') ..write('timeZone: $timeZone, ') ..write('accessRole: $accessRole, ') + ..write('dataOwner: $dataOwner, ') + ..write('isRemovable: $isRemovable, ') ..write('isDeleted: $isDeleted, ') ..write('rawJson: $rawJson, ') ..write('createdAtLocal: $createdAtLocal, ') @@ -15667,6 +15801,8 @@ class CalendarSource extends DataClass implements Insertable { colorId, timeZone, accessRole, + dataOwner, + isRemovable, isDeleted, rawJson, createdAtLocal, @@ -15693,6 +15829,8 @@ class CalendarSource extends DataClass implements Insertable { other.colorId == this.colorId && other.timeZone == this.timeZone && other.accessRole == this.accessRole && + other.dataOwner == this.dataOwner && + other.isRemovable == this.isRemovable && other.isDeleted == this.isDeleted && other.rawJson == this.rawJson && other.createdAtLocal == this.createdAtLocal && @@ -15717,6 +15855,8 @@ class CalendarSourcesCompanion extends UpdateCompanion { final Value colorId; final Value timeZone; final Value accessRole; + final Value dataOwner; + final Value isRemovable; final Value isDeleted; final Value rawJson; final Value createdAtLocal; @@ -15740,6 +15880,8 @@ class CalendarSourcesCompanion extends UpdateCompanion { this.colorId = const Value.absent(), this.timeZone = const Value.absent(), this.accessRole = const Value.absent(), + this.dataOwner = const Value.absent(), + this.isRemovable = const Value.absent(), this.isDeleted = const Value.absent(), this.rawJson = const Value.absent(), this.createdAtLocal = const Value.absent(), @@ -15764,6 +15906,8 @@ class CalendarSourcesCompanion extends UpdateCompanion { this.colorId = const Value.absent(), this.timeZone = const Value.absent(), this.accessRole = const Value.absent(), + this.dataOwner = const Value.absent(), + this.isRemovable = const Value.absent(), this.isDeleted = const Value.absent(), this.rawJson = const Value.absent(), required int createdAtLocal, @@ -15794,6 +15938,8 @@ class CalendarSourcesCompanion extends UpdateCompanion { Expression? colorId, Expression? timeZone, Expression? accessRole, + Expression? dataOwner, + Expression? isRemovable, Expression? isDeleted, Expression? rawJson, Expression? createdAtLocal, @@ -15819,6 +15965,8 @@ class CalendarSourcesCompanion extends UpdateCompanion { if (colorId != null) 'color_id': colorId, if (timeZone != null) 'time_zone': timeZone, if (accessRole != null) 'access_role': accessRole, + if (dataOwner != null) 'data_owner': dataOwner, + if (isRemovable != null) 'is_removable': isRemovable, if (isDeleted != null) 'is_deleted': isDeleted, if (rawJson != null) 'raw_json': rawJson, if (createdAtLocal != null) 'created_at_local': createdAtLocal, @@ -15845,6 +15993,8 @@ class CalendarSourcesCompanion extends UpdateCompanion { Value? colorId, Value? timeZone, Value? accessRole, + Value? dataOwner, + Value? isRemovable, Value? isDeleted, Value? rawJson, Value? createdAtLocal, @@ -15869,6 +16019,8 @@ class CalendarSourcesCompanion extends UpdateCompanion { colorId: colorId ?? this.colorId, timeZone: timeZone ?? this.timeZone, accessRole: accessRole ?? this.accessRole, + dataOwner: dataOwner ?? this.dataOwner, + isRemovable: isRemovable ?? this.isRemovable, isDeleted: isDeleted ?? this.isDeleted, rawJson: rawJson ?? this.rawJson, createdAtLocal: createdAtLocal ?? this.createdAtLocal, @@ -15931,6 +16083,12 @@ class CalendarSourcesCompanion extends UpdateCompanion { if (accessRole.present) { map['access_role'] = Variable(accessRole.value); } + if (dataOwner.present) { + map['data_owner'] = Variable(dataOwner.value); + } + if (isRemovable.present) { + map['is_removable'] = Variable(isRemovable.value); + } if (isDeleted.present) { map['is_deleted'] = Variable(isDeleted.value); } @@ -15969,6 +16127,8 @@ class CalendarSourcesCompanion extends UpdateCompanion { ..write('colorId: $colorId, ') ..write('timeZone: $timeZone, ') ..write('accessRole: $accessRole, ') + ..write('dataOwner: $dataOwner, ') + ..write('isRemovable: $isRemovable, ') ..write('isDeleted: $isDeleted, ') ..write('rawJson: $rawJson, ') ..write('createdAtLocal: $createdAtLocal, ') @@ -18783,534 +18943,304 @@ class CalendarEventsCompanion extends UpdateCompanion { } } -class $CalendarEventAttendeesTable extends CalendarEventAttendees - with TableInfo<$CalendarEventAttendeesTable, CalendarEventAttendee> { +class $IcalImportReceiptsTable extends IcalImportReceipts + with TableInfo<$IcalImportReceiptsTable, IcalImportReceipt> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $CalendarEventAttendeesTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _calendarEventIdMeta = const VerificationMeta( - 'calendarEventId', + $IcalImportReceiptsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _calendarSourceIdMeta = const VerificationMeta( + 'calendarSourceId', ); @override - late final GeneratedColumn calendarEventId = GeneratedColumn( - 'calendar_event_id', + late final GeneratedColumn calendarSourceId = GeneratedColumn( + 'calendar_source_id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES calendar_events (id) ON DELETE CASCADE', + 'REFERENCES calendar_sources (id) ON DELETE CASCADE', ), ); - static const VerificationMeta _emailMeta = const VerificationMeta('email'); + static const VerificationMeta _icalUidMeta = const VerificationMeta( + 'icalUid', + ); @override - late final GeneratedColumn email = GeneratedColumn( - 'email', + late final GeneratedColumn icalUid = GeneratedColumn( + 'ical_uid', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _displayNameMeta = const VerificationMeta( - 'displayName', - ); - @override - late final GeneratedColumn displayName = GeneratedColumn( - 'display_name', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _responseStatusMeta = const VerificationMeta( - 'responseStatus', + static const VerificationMeta _eventIdMeta = const VerificationMeta( + 'eventId', ); @override - late final GeneratedColumn responseStatus = GeneratedColumn( - 'response_status', + late final GeneratedColumn eventId = GeneratedColumn( + 'event_id', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _optionalMeta = const VerificationMeta( - 'optional', - ); - @override - late final GeneratedColumn optional = GeneratedColumn( - 'optional', - aliasedName, - false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("optional" IN (0, 1))', - ), - defaultValue: const Constant(false), - ); - static const VerificationMeta _organizerMeta = const VerificationMeta( - 'organizer', - ); - @override - late final GeneratedColumn organizer = GeneratedColumn( - 'organizer', - aliasedName, - false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("organizer" IN (0, 1))', - ), - defaultValue: const Constant(false), + static const VerificationMeta _importedAtUtcMeta = const VerificationMeta( + 'importedAtUtc', ); - static const VerificationMeta _selfMeta = const VerificationMeta('self'); @override - late final GeneratedColumn self = GeneratedColumn( - 'self', + late final GeneratedColumn importedAtUtc = GeneratedColumn( + 'imported_at_utc', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("self" IN (0, 1))', - ), - defaultValue: const Constant(false), - ); - static const VerificationMeta _rawJsonMeta = const VerificationMeta( - 'rawJson', - ); - @override - late final GeneratedColumn rawJson = GeneratedColumn( - 'raw_json', - aliasedName, - true, type: DriftSqlType.string, - requiredDuringInsert: false, + requiredDuringInsert: true, ); @override List get $columns => [ - id, - calendarEventId, - email, - displayName, - responseStatus, - optional, - organizer, - self, - rawJson, + calendarSourceId, + icalUid, + eventId, + importedAtUtc, ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'calendar_event_attendees'; + static const String $name = 'ical_import_receipts'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } else if (isInserting) { - context.missing(_idMeta); - } - if (data.containsKey('calendar_event_id')) { + if (data.containsKey('calendar_source_id')) { context.handle( - _calendarEventIdMeta, - calendarEventId.isAcceptableOrUnknown( - data['calendar_event_id']!, - _calendarEventIdMeta, + _calendarSourceIdMeta, + calendarSourceId.isAcceptableOrUnknown( + data['calendar_source_id']!, + _calendarSourceIdMeta, ), ); } else if (isInserting) { - context.missing(_calendarEventIdMeta); + context.missing(_calendarSourceIdMeta); } - if (data.containsKey('email')) { + if (data.containsKey('ical_uid')) { context.handle( - _emailMeta, - email.isAcceptableOrUnknown(data['email']!, _emailMeta), + _icalUidMeta, + icalUid.isAcceptableOrUnknown(data['ical_uid']!, _icalUidMeta), ); } else if (isInserting) { - context.missing(_emailMeta); + context.missing(_icalUidMeta); } - if (data.containsKey('display_name')) { + if (data.containsKey('event_id')) { context.handle( - _displayNameMeta, - displayName.isAcceptableOrUnknown( - data['display_name']!, - _displayNameMeta, - ), + _eventIdMeta, + eventId.isAcceptableOrUnknown(data['event_id']!, _eventIdMeta), ); } - if (data.containsKey('response_status')) { + if (data.containsKey('imported_at_utc')) { context.handle( - _responseStatusMeta, - responseStatus.isAcceptableOrUnknown( - data['response_status']!, - _responseStatusMeta, + _importedAtUtcMeta, + importedAtUtc.isAcceptableOrUnknown( + data['imported_at_utc']!, + _importedAtUtcMeta, ), ); - } - if (data.containsKey('optional')) { - context.handle( - _optionalMeta, - optional.isAcceptableOrUnknown(data['optional']!, _optionalMeta), - ); - } - if (data.containsKey('organizer')) { - context.handle( - _organizerMeta, - organizer.isAcceptableOrUnknown(data['organizer']!, _organizerMeta), - ); - } - if (data.containsKey('self')) { - context.handle( - _selfMeta, - self.isAcceptableOrUnknown(data['self']!, _selfMeta), - ); - } - if (data.containsKey('raw_json')) { - context.handle( - _rawJsonMeta, - rawJson.isAcceptableOrUnknown(data['raw_json']!, _rawJsonMeta), - ); + } else if (isInserting) { + context.missing(_importedAtUtcMeta); } return context; } @override - Set get $primaryKey => {id}; + Set get $primaryKey => {calendarSourceId, icalUid}; @override - CalendarEventAttendee map(Map data, {String? tablePrefix}) { + IcalImportReceipt map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return CalendarEventAttendee( - id: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}id'], - )!, - calendarEventId: attachedDatabase.typeMapping.read( + return IcalImportReceipt( + calendarSourceId: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}calendar_event_id'], + data['${effectivePrefix}calendar_source_id'], )!, - email: attachedDatabase.typeMapping.read( + icalUid: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}email'], + data['${effectivePrefix}ical_uid'], )!, - displayName: attachedDatabase.typeMapping.read( + eventId: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}display_name'], + data['${effectivePrefix}event_id'], ), - responseStatus: attachedDatabase.typeMapping.read( + importedAtUtc: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}response_status'], - ), - optional: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}optional'], - )!, - organizer: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}organizer'], - )!, - self: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}self'], + data['${effectivePrefix}imported_at_utc'], )!, - rawJson: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}raw_json'], - ), ); } @override - $CalendarEventAttendeesTable createAlias(String alias) { - return $CalendarEventAttendeesTable(attachedDatabase, alias); + $IcalImportReceiptsTable createAlias(String alias) { + return $IcalImportReceiptsTable(attachedDatabase, alias); } } -class CalendarEventAttendee extends DataClass - implements Insertable { - final String id; - final String calendarEventId; - final String email; - final String? displayName; - final String? responseStatus; - final bool optional; - final bool organizer; - final bool self; - final String? rawJson; - const CalendarEventAttendee({ - required this.id, - required this.calendarEventId, - required this.email, - this.displayName, - this.responseStatus, - required this.optional, - required this.organizer, - required this.self, - this.rawJson, +class IcalImportReceipt extends DataClass + implements Insertable { + final String calendarSourceId; + final String icalUid; + final String? eventId; + final String importedAtUtc; + const IcalImportReceipt({ + required this.calendarSourceId, + required this.icalUid, + this.eventId, + required this.importedAtUtc, }); @override Map toColumns(bool nullToAbsent) { final map = {}; - map['id'] = Variable(id); - map['calendar_event_id'] = Variable(calendarEventId); - map['email'] = Variable(email); - if (!nullToAbsent || displayName != null) { - map['display_name'] = Variable(displayName); - } - if (!nullToAbsent || responseStatus != null) { - map['response_status'] = Variable(responseStatus); - } - map['optional'] = Variable(optional); - map['organizer'] = Variable(organizer); - map['self'] = Variable(self); - if (!nullToAbsent || rawJson != null) { - map['raw_json'] = Variable(rawJson); + map['calendar_source_id'] = Variable(calendarSourceId); + map['ical_uid'] = Variable(icalUid); + if (!nullToAbsent || eventId != null) { + map['event_id'] = Variable(eventId); } + map['imported_at_utc'] = Variable(importedAtUtc); return map; } - CalendarEventAttendeesCompanion toCompanion(bool nullToAbsent) { - return CalendarEventAttendeesCompanion( - id: Value(id), - calendarEventId: Value(calendarEventId), - email: Value(email), - displayName: displayName == null && nullToAbsent - ? const Value.absent() - : Value(displayName), - responseStatus: responseStatus == null && nullToAbsent - ? const Value.absent() - : Value(responseStatus), - optional: Value(optional), - organizer: Value(organizer), - self: Value(self), - rawJson: rawJson == null && nullToAbsent + IcalImportReceiptsCompanion toCompanion(bool nullToAbsent) { + return IcalImportReceiptsCompanion( + calendarSourceId: Value(calendarSourceId), + icalUid: Value(icalUid), + eventId: eventId == null && nullToAbsent ? const Value.absent() - : Value(rawJson), + : Value(eventId), + importedAtUtc: Value(importedAtUtc), ); } - factory CalendarEventAttendee.fromJson( + factory IcalImportReceipt.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return CalendarEventAttendee( - id: serializer.fromJson(json['id']), - calendarEventId: serializer.fromJson(json['calendarEventId']), - email: serializer.fromJson(json['email']), - displayName: serializer.fromJson(json['displayName']), - responseStatus: serializer.fromJson(json['responseStatus']), - optional: serializer.fromJson(json['optional']), - organizer: serializer.fromJson(json['organizer']), - self: serializer.fromJson(json['self']), - rawJson: serializer.fromJson(json['rawJson']), + return IcalImportReceipt( + calendarSourceId: serializer.fromJson(json['calendarSourceId']), + icalUid: serializer.fromJson(json['icalUid']), + eventId: serializer.fromJson(json['eventId']), + importedAtUtc: serializer.fromJson(json['importedAtUtc']), ); } @override Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { - 'id': serializer.toJson(id), - 'calendarEventId': serializer.toJson(calendarEventId), - 'email': serializer.toJson(email), - 'displayName': serializer.toJson(displayName), - 'responseStatus': serializer.toJson(responseStatus), - 'optional': serializer.toJson(optional), - 'organizer': serializer.toJson(organizer), - 'self': serializer.toJson(self), - 'rawJson': serializer.toJson(rawJson), + 'calendarSourceId': serializer.toJson(calendarSourceId), + 'icalUid': serializer.toJson(icalUid), + 'eventId': serializer.toJson(eventId), + 'importedAtUtc': serializer.toJson(importedAtUtc), }; } - CalendarEventAttendee copyWith({ - String? id, - String? calendarEventId, - String? email, - Value displayName = const Value.absent(), - Value responseStatus = const Value.absent(), - bool? optional, - bool? organizer, - bool? self, - Value rawJson = const Value.absent(), - }) => CalendarEventAttendee( - id: id ?? this.id, - calendarEventId: calendarEventId ?? this.calendarEventId, - email: email ?? this.email, - displayName: displayName.present ? displayName.value : this.displayName, - responseStatus: responseStatus.present - ? responseStatus.value - : this.responseStatus, - optional: optional ?? this.optional, - organizer: organizer ?? this.organizer, - self: self ?? this.self, - rawJson: rawJson.present ? rawJson.value : this.rawJson, + IcalImportReceipt copyWith({ + String? calendarSourceId, + String? icalUid, + Value eventId = const Value.absent(), + String? importedAtUtc, + }) => IcalImportReceipt( + calendarSourceId: calendarSourceId ?? this.calendarSourceId, + icalUid: icalUid ?? this.icalUid, + eventId: eventId.present ? eventId.value : this.eventId, + importedAtUtc: importedAtUtc ?? this.importedAtUtc, ); - CalendarEventAttendee copyWithCompanion( - CalendarEventAttendeesCompanion data, - ) { - return CalendarEventAttendee( - id: data.id.present ? data.id.value : this.id, - calendarEventId: data.calendarEventId.present - ? data.calendarEventId.value - : this.calendarEventId, - email: data.email.present ? data.email.value : this.email, - displayName: data.displayName.present - ? data.displayName.value - : this.displayName, - responseStatus: data.responseStatus.present - ? data.responseStatus.value - : this.responseStatus, - optional: data.optional.present ? data.optional.value : this.optional, - organizer: data.organizer.present ? data.organizer.value : this.organizer, - self: data.self.present ? data.self.value : this.self, - rawJson: data.rawJson.present ? data.rawJson.value : this.rawJson, + IcalImportReceipt copyWithCompanion(IcalImportReceiptsCompanion data) { + return IcalImportReceipt( + calendarSourceId: data.calendarSourceId.present + ? data.calendarSourceId.value + : this.calendarSourceId, + icalUid: data.icalUid.present ? data.icalUid.value : this.icalUid, + eventId: data.eventId.present ? data.eventId.value : this.eventId, + importedAtUtc: data.importedAtUtc.present + ? data.importedAtUtc.value + : this.importedAtUtc, ); } @override String toString() { - return (StringBuffer('CalendarEventAttendee(') - ..write('id: $id, ') - ..write('calendarEventId: $calendarEventId, ') - ..write('email: $email, ') - ..write('displayName: $displayName, ') - ..write('responseStatus: $responseStatus, ') - ..write('optional: $optional, ') - ..write('organizer: $organizer, ') - ..write('self: $self, ') - ..write('rawJson: $rawJson') + return (StringBuffer('IcalImportReceipt(') + ..write('calendarSourceId: $calendarSourceId, ') + ..write('icalUid: $icalUid, ') + ..write('eventId: $eventId, ') + ..write('importedAtUtc: $importedAtUtc') ..write(')')) .toString(); } @override - int get hashCode => Object.hash( - id, - calendarEventId, - email, - displayName, - responseStatus, - optional, - organizer, - self, - rawJson, - ); + int get hashCode => + Object.hash(calendarSourceId, icalUid, eventId, importedAtUtc); @override bool operator ==(Object other) => identical(this, other) || - (other is CalendarEventAttendee && - other.id == this.id && - other.calendarEventId == this.calendarEventId && - other.email == this.email && - other.displayName == this.displayName && - other.responseStatus == this.responseStatus && - other.optional == this.optional && - other.organizer == this.organizer && - other.self == this.self && - other.rawJson == this.rawJson); + (other is IcalImportReceipt && + other.calendarSourceId == this.calendarSourceId && + other.icalUid == this.icalUid && + other.eventId == this.eventId && + other.importedAtUtc == this.importedAtUtc); } -class CalendarEventAttendeesCompanion - extends UpdateCompanion { - final Value id; - final Value calendarEventId; - final Value email; - final Value displayName; - final Value responseStatus; - final Value optional; - final Value organizer; - final Value self; - final Value rawJson; +class IcalImportReceiptsCompanion extends UpdateCompanion { + final Value calendarSourceId; + final Value icalUid; + final Value eventId; + final Value importedAtUtc; final Value rowid; - const CalendarEventAttendeesCompanion({ - this.id = const Value.absent(), - this.calendarEventId = const Value.absent(), - this.email = const Value.absent(), - this.displayName = const Value.absent(), - this.responseStatus = const Value.absent(), - this.optional = const Value.absent(), - this.organizer = const Value.absent(), - this.self = const Value.absent(), - this.rawJson = const Value.absent(), + const IcalImportReceiptsCompanion({ + this.calendarSourceId = const Value.absent(), + this.icalUid = const Value.absent(), + this.eventId = const Value.absent(), + this.importedAtUtc = const Value.absent(), this.rowid = const Value.absent(), }); - CalendarEventAttendeesCompanion.insert({ - required String id, - required String calendarEventId, - required String email, - this.displayName = const Value.absent(), - this.responseStatus = const Value.absent(), - this.optional = const Value.absent(), - this.organizer = const Value.absent(), - this.self = const Value.absent(), - this.rawJson = const Value.absent(), + IcalImportReceiptsCompanion.insert({ + required String calendarSourceId, + required String icalUid, + this.eventId = const Value.absent(), + required String importedAtUtc, this.rowid = const Value.absent(), - }) : id = Value(id), - calendarEventId = Value(calendarEventId), - email = Value(email); - static Insertable custom({ - Expression? id, - Expression? calendarEventId, - Expression? email, - Expression? displayName, - Expression? responseStatus, - Expression? optional, - Expression? organizer, - Expression? self, - Expression? rawJson, + }) : calendarSourceId = Value(calendarSourceId), + icalUid = Value(icalUid), + importedAtUtc = Value(importedAtUtc); + static Insertable custom({ + Expression? calendarSourceId, + Expression? icalUid, + Expression? eventId, + Expression? importedAtUtc, Expression? rowid, }) { return RawValuesInsertable({ - if (id != null) 'id': id, - if (calendarEventId != null) 'calendar_event_id': calendarEventId, - if (email != null) 'email': email, - if (displayName != null) 'display_name': displayName, - if (responseStatus != null) 'response_status': responseStatus, - if (optional != null) 'optional': optional, - if (organizer != null) 'organizer': organizer, - if (self != null) 'self': self, - if (rawJson != null) 'raw_json': rawJson, + if (calendarSourceId != null) 'calendar_source_id': calendarSourceId, + if (icalUid != null) 'ical_uid': icalUid, + if (eventId != null) 'event_id': eventId, + if (importedAtUtc != null) 'imported_at_utc': importedAtUtc, if (rowid != null) 'rowid': rowid, }); } - CalendarEventAttendeesCompanion copyWith({ - Value? id, - Value? calendarEventId, - Value? email, - Value? displayName, - Value? responseStatus, - Value? optional, - Value? organizer, - Value? self, - Value? rawJson, + IcalImportReceiptsCompanion copyWith({ + Value? calendarSourceId, + Value? icalUid, + Value? eventId, + Value? importedAtUtc, Value? rowid, }) { - return CalendarEventAttendeesCompanion( - id: id ?? this.id, - calendarEventId: calendarEventId ?? this.calendarEventId, - email: email ?? this.email, - displayName: displayName ?? this.displayName, - responseStatus: responseStatus ?? this.responseStatus, - optional: optional ?? this.optional, - organizer: organizer ?? this.organizer, - self: self ?? this.self, - rawJson: rawJson ?? this.rawJson, + return IcalImportReceiptsCompanion( + calendarSourceId: calendarSourceId ?? this.calendarSourceId, + icalUid: icalUid ?? this.icalUid, + eventId: eventId ?? this.eventId, + importedAtUtc: importedAtUtc ?? this.importedAtUtc, rowid: rowid ?? this.rowid, ); } @@ -19318,32 +19248,17 @@ class CalendarEventAttendeesCompanion @override Map toColumns(bool nullToAbsent) { final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (calendarEventId.present) { - map['calendar_event_id'] = Variable(calendarEventId.value); - } - if (email.present) { - map['email'] = Variable(email.value); - } - if (displayName.present) { - map['display_name'] = Variable(displayName.value); - } - if (responseStatus.present) { - map['response_status'] = Variable(responseStatus.value); - } - if (optional.present) { - map['optional'] = Variable(optional.value); + if (calendarSourceId.present) { + map['calendar_source_id'] = Variable(calendarSourceId.value); } - if (organizer.present) { - map['organizer'] = Variable(organizer.value); + if (icalUid.present) { + map['ical_uid'] = Variable(icalUid.value); } - if (self.present) { - map['self'] = Variable(self.value); + if (eventId.present) { + map['event_id'] = Variable(eventId.value); } - if (rawJson.present) { - map['raw_json'] = Variable(rawJson.value); + if (importedAtUtc.present) { + map['imported_at_utc'] = Variable(importedAtUtc.value); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -19353,28 +19268,23 @@ class CalendarEventAttendeesCompanion @override String toString() { - return (StringBuffer('CalendarEventAttendeesCompanion(') - ..write('id: $id, ') - ..write('calendarEventId: $calendarEventId, ') - ..write('email: $email, ') - ..write('displayName: $displayName, ') - ..write('responseStatus: $responseStatus, ') - ..write('optional: $optional, ') - ..write('organizer: $organizer, ') - ..write('self: $self, ') - ..write('rawJson: $rawJson, ') + return (StringBuffer('IcalImportReceiptsCompanion(') + ..write('calendarSourceId: $calendarSourceId, ') + ..write('icalUid: $icalUid, ') + ..write('eventId: $eventId, ') + ..write('importedAtUtc: $importedAtUtc, ') ..write('rowid: $rowid') ..write(')')) .toString(); } } -class $CalendarEventRemindersTable extends CalendarEventReminders - with TableInfo<$CalendarEventRemindersTable, CalendarEventReminder> { +class $WebCalSubscriptionsTable extends WebCalSubscriptions + with TableInfo<$WebCalSubscriptionsTable, WebCalSubscription> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $CalendarEventRemindersTable(this.attachedDatabase, [this._alias]); + $WebCalSubscriptionsTable(this.attachedDatabase, [this._alias]); static const VerificationMeta _idMeta = const VerificationMeta('id'); @override late final GeneratedColumn id = GeneratedColumn( @@ -19384,107 +19294,350 @@ class $CalendarEventRemindersTable extends CalendarEventReminders type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _calendarEventIdMeta = const VerificationMeta( - 'calendarEventId', + static const VerificationMeta _accountIdMeta = const VerificationMeta( + 'accountId', ); @override - late final GeneratedColumn calendarEventId = GeneratedColumn( - 'calendar_event_id', + late final GeneratedColumn accountId = GeneratedColumn( + 'account_id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES calendar_events (id) ON DELETE CASCADE', + 'REFERENCES accounts (id) ON DELETE CASCADE', ), ); - static const VerificationMeta _providerMeta = const VerificationMeta( - 'provider', + static const VerificationMeta _calendarSourceIdMeta = const VerificationMeta( + 'calendarSourceId', ); @override - late final GeneratedColumn provider = GeneratedColumn( - 'provider', + late final GeneratedColumn calendarSourceId = GeneratedColumn( + 'calendar_source_id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES calendar_sources (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _feedFingerprintMeta = const VerificationMeta( + 'feedFingerprint', ); - static const VerificationMeta _methodMeta = const VerificationMeta('method'); @override - late final GeneratedColumn method = GeneratedColumn( - 'method', + late final GeneratedColumn feedFingerprint = GeneratedColumn( + 'feed_fingerprint', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'), + ); + static const VerificationMeta _safeOriginMeta = const VerificationMeta( + 'safeOrigin', + ); + @override + late final GeneratedColumn safeOrigin = GeneratedColumn( + 'safe_origin', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _validatorTargetFingerprintMeta = + const VerificationMeta('validatorTargetFingerprint'); + @override + late final GeneratedColumn validatorTargetFingerprint = + GeneratedColumn( + 'validator_target_fingerprint', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _etagMeta = const VerificationMeta('etag'); + @override + late final GeneratedColumn etag = GeneratedColumn( + 'etag', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _minutesBeforeMeta = const VerificationMeta( - 'minutesBefore', + static const VerificationMeta _lastModifiedMeta = const VerificationMeta( + 'lastModified', ); @override - late final GeneratedColumn minutesBefore = GeneratedColumn( - 'minutes_before', + late final GeneratedColumn lastModified = GeneratedColumn( + 'last_modified', aliasedName, true, - type: DriftSqlType.int, + type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _absoluteTimeMeta = const VerificationMeta( - 'absoluteTime', + static const VerificationMeta _contentTypeMeta = const VerificationMeta( + 'contentType', ); @override - late final GeneratedColumn absoluteTime = GeneratedColumn( - 'absolute_time', + late final GeneratedColumn contentType = GeneratedColumn( + 'content_type', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _enabledMeta = const VerificationMeta( - 'enabled', + static const VerificationMeta _snapshotIcsBodyMeta = const VerificationMeta( + 'snapshotIcsBody', ); @override - late final GeneratedColumn enabled = GeneratedColumn( - 'enabled', + late final GeneratedColumn snapshotIcsBody = GeneratedColumn( + 'snapshot_ics_body', aliasedName, false, - type: DriftSqlType.bool, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _rawBodyHashMeta = const VerificationMeta( + 'rawBodyHash', + ); + @override + late final GeneratedColumn rawBodyHash = GeneratedColumn( + 'raw_body_hash', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _semanticHashMeta = const VerificationMeta( + 'semanticHash', + ); + @override + late final GeneratedColumn semanticHash = GeneratedColumn( + 'semantic_hash', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _refreshModeMeta = const VerificationMeta( + 'refreshMode', + ); + @override + late final GeneratedColumn refreshMode = GeneratedColumn( + 'refresh_mode', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _serverRefreshIntervalSecondsMeta = + const VerificationMeta('serverRefreshIntervalSeconds'); + @override + late final GeneratedColumn serverRefreshIntervalSeconds = + GeneratedColumn( + 'server_refresh_interval_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _nextRefreshAtUtcMeta = const VerificationMeta( + 'nextRefreshAtUtc', + ); + @override + late final GeneratedColumn nextRefreshAtUtc = GeneratedColumn( + 'next_refresh_at_utc', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _lastCheckedAtUtcMeta = const VerificationMeta( + 'lastCheckedAtUtc', + ); + @override + late final GeneratedColumn lastCheckedAtUtc = GeneratedColumn( + 'last_checked_at_utc', + aliasedName, + true, + type: DriftSqlType.string, requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("enabled" IN (0, 1))', - ), - defaultValue: const Constant(true), ); - static const VerificationMeta _rawJsonMeta = const VerificationMeta( - 'rawJson', + static const VerificationMeta _lastSuccessfulSyncAtUtcMeta = + const VerificationMeta('lastSuccessfulSyncAtUtc'); + @override + late final GeneratedColumn lastSuccessfulSyncAtUtc = + GeneratedColumn( + 'last_successful_sync_at_utc', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastChangedAtUtcMeta = const VerificationMeta( + 'lastChangedAtUtc', ); @override - late final GeneratedColumn rawJson = GeneratedColumn( - 'raw_json', + late final GeneratedColumn lastChangedAtUtc = GeneratedColumn( + 'last_changed_at_utc', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _consecutiveFailureCountMeta = + const VerificationMeta('consecutiveFailureCount'); + @override + late final GeneratedColumn consecutiveFailureCount = + GeneratedColumn( + 'consecutive_failure_count', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _lastFailureCodeMeta = const VerificationMeta( + 'lastFailureCode', + ); + @override + late final GeneratedColumn lastFailureCode = GeneratedColumn( + 'last_failure_code', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _lastFailureHttpStatusMeta = + const VerificationMeta('lastFailureHttpStatus'); + @override + late final GeneratedColumn lastFailureHttpStatus = GeneratedColumn( + 'last_failure_http_status', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _generationMeta = const VerificationMeta( + 'generation', + ); + @override + late final GeneratedColumn generation = GeneratedColumn( + 'generation', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(1), + ); + static const VerificationMeta _parserVersionMeta = const VerificationMeta( + 'parserVersion', + ); + @override + late final GeneratedColumn parserVersion = GeneratedColumn( + 'parser_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(1), + ); + static const VerificationMeta _projectionVersionMeta = const VerificationMeta( + 'projectionVersion', + ); + @override + late final GeneratedColumn projectionVersion = GeneratedColumn( + 'projection_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(1), + ); + static const VerificationMeta _projectionRangeStartUtcMeta = + const VerificationMeta('projectionRangeStartUtc'); + @override + late final GeneratedColumn projectionRangeStartUtc = + GeneratedColumn( + 'projection_range_start_utc', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _projectionRangeEndUtcMeta = + const VerificationMeta('projectionRangeEndUtc'); + @override + late final GeneratedColumn projectionRangeEndUtc = + GeneratedColumn( + 'projection_range_end_utc', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtUtcMeta = const VerificationMeta( + 'createdAtUtc', + ); + @override + late final GeneratedColumn createdAtUtc = GeneratedColumn( + 'created_at_utc', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtUtcMeta = const VerificationMeta( + 'updatedAtUtc', + ); + @override + late final GeneratedColumn updatedAtUtc = GeneratedColumn( + 'updated_at_utc', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); @override List get $columns => [ id, - calendarEventId, - provider, - method, - minutesBefore, - absoluteTime, - enabled, - rawJson, + accountId, + calendarSourceId, + feedFingerprint, + safeOrigin, + validatorTargetFingerprint, + etag, + lastModified, + contentType, + snapshotIcsBody, + rawBodyHash, + semanticHash, + refreshMode, + serverRefreshIntervalSeconds, + nextRefreshAtUtc, + lastCheckedAtUtc, + lastSuccessfulSyncAtUtc, + lastChangedAtUtc, + consecutiveFailureCount, + lastFailureCode, + lastFailureHttpStatus, + generation, + parserVersion, + projectionVersion, + projectionRangeStartUtc, + projectionRangeEndUtc, + createdAtUtc, + updatedAtUtc, ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'calendar_event_reminders'; + static const String $name = 'web_cal_subscriptions'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); @@ -19494,188 +19647,625 @@ class $CalendarEventRemindersTable extends CalendarEventReminders } else if (isInserting) { context.missing(_idMeta); } - if (data.containsKey('calendar_event_id')) { + if (data.containsKey('account_id')) { context.handle( - _calendarEventIdMeta, - calendarEventId.isAcceptableOrUnknown( - data['calendar_event_id']!, - _calendarEventIdMeta, - ), + _accountIdMeta, + accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta), ); } else if (isInserting) { - context.missing(_calendarEventIdMeta); + context.missing(_accountIdMeta); } - if (data.containsKey('provider')) { + if (data.containsKey('calendar_source_id')) { context.handle( - _providerMeta, - provider.isAcceptableOrUnknown(data['provider']!, _providerMeta), + _calendarSourceIdMeta, + calendarSourceId.isAcceptableOrUnknown( + data['calendar_source_id']!, + _calendarSourceIdMeta, + ), ); } else if (isInserting) { - context.missing(_providerMeta); + context.missing(_calendarSourceIdMeta); } - if (data.containsKey('method')) { + if (data.containsKey('feed_fingerprint')) { context.handle( - _methodMeta, - method.isAcceptableOrUnknown(data['method']!, _methodMeta), + _feedFingerprintMeta, + feedFingerprint.isAcceptableOrUnknown( + data['feed_fingerprint']!, + _feedFingerprintMeta, + ), ); + } else if (isInserting) { + context.missing(_feedFingerprintMeta); } - if (data.containsKey('minutes_before')) { + if (data.containsKey('safe_origin')) { context.handle( - _minutesBeforeMeta, - minutesBefore.isAcceptableOrUnknown( - data['minutes_before']!, - _minutesBeforeMeta, - ), + _safeOriginMeta, + safeOrigin.isAcceptableOrUnknown(data['safe_origin']!, _safeOriginMeta), ); + } else if (isInserting) { + context.missing(_safeOriginMeta); } - if (data.containsKey('absolute_time')) { + if (data.containsKey('validator_target_fingerprint')) { context.handle( - _absoluteTimeMeta, - absoluteTime.isAcceptableOrUnknown( - data['absolute_time']!, - _absoluteTimeMeta, + _validatorTargetFingerprintMeta, + validatorTargetFingerprint.isAcceptableOrUnknown( + data['validator_target_fingerprint']!, + _validatorTargetFingerprintMeta, ), ); } - if (data.containsKey('enabled')) { + if (data.containsKey('etag')) { context.handle( - _enabledMeta, - enabled.isAcceptableOrUnknown(data['enabled']!, _enabledMeta), + _etagMeta, + etag.isAcceptableOrUnknown(data['etag']!, _etagMeta), ); } - if (data.containsKey('raw_json')) { + if (data.containsKey('last_modified')) { context.handle( - _rawJsonMeta, - rawJson.isAcceptableOrUnknown(data['raw_json']!, _rawJsonMeta), + _lastModifiedMeta, + lastModified.isAcceptableOrUnknown( + data['last_modified']!, + _lastModifiedMeta, + ), ); } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - CalendarEventReminder map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return CalendarEventReminder( - id: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}id'], - )!, - calendarEventId: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}calendar_event_id'], - )!, - provider: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}provider'], - )!, - method: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}method'], - ), - minutesBefore: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}minutes_before'], - ), - absoluteTime: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}absolute_time'], - ), - enabled: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}enabled'], - )!, - rawJson: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}raw_json'], - ), - ); - } - - @override - $CalendarEventRemindersTable createAlias(String alias) { - return $CalendarEventRemindersTable(attachedDatabase, alias); - } -} - -class CalendarEventReminder extends DataClass - implements Insertable { - final String id; - final String calendarEventId; - final String provider; - final String? method; - final int? minutesBefore; - final String? absoluteTime; - final bool enabled; - final String? rawJson; - const CalendarEventReminder({ - required this.id, - required this.calendarEventId, - required this.provider, - this.method, - this.minutesBefore, - this.absoluteTime, - required this.enabled, - this.rawJson, - }); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['calendar_event_id'] = Variable(calendarEventId); - map['provider'] = Variable(provider); - if (!nullToAbsent || method != null) { - map['method'] = Variable(method); + if (data.containsKey('content_type')) { + context.handle( + _contentTypeMeta, + contentType.isAcceptableOrUnknown( + data['content_type']!, + _contentTypeMeta, + ), + ); } - if (!nullToAbsent || minutesBefore != null) { - map['minutes_before'] = Variable(minutesBefore); + if (data.containsKey('snapshot_ics_body')) { + context.handle( + _snapshotIcsBodyMeta, + snapshotIcsBody.isAcceptableOrUnknown( + data['snapshot_ics_body']!, + _snapshotIcsBodyMeta, + ), + ); + } else if (isInserting) { + context.missing(_snapshotIcsBodyMeta); } - if (!nullToAbsent || absoluteTime != null) { - map['absolute_time'] = Variable(absoluteTime); + if (data.containsKey('raw_body_hash')) { + context.handle( + _rawBodyHashMeta, + rawBodyHash.isAcceptableOrUnknown( + data['raw_body_hash']!, + _rawBodyHashMeta, + ), + ); + } else if (isInserting) { + context.missing(_rawBodyHashMeta); } - map['enabled'] = Variable(enabled); - if (!nullToAbsent || rawJson != null) { - map['raw_json'] = Variable(rawJson); + if (data.containsKey('semantic_hash')) { + context.handle( + _semanticHashMeta, + semanticHash.isAcceptableOrUnknown( + data['semantic_hash']!, + _semanticHashMeta, + ), + ); + } else if (isInserting) { + context.missing(_semanticHashMeta); + } + if (data.containsKey('refresh_mode')) { + context.handle( + _refreshModeMeta, + refreshMode.isAcceptableOrUnknown( + data['refresh_mode']!, + _refreshModeMeta, + ), + ); + } else if (isInserting) { + context.missing(_refreshModeMeta); + } + if (data.containsKey('server_refresh_interval_seconds')) { + context.handle( + _serverRefreshIntervalSecondsMeta, + serverRefreshIntervalSeconds.isAcceptableOrUnknown( + data['server_refresh_interval_seconds']!, + _serverRefreshIntervalSecondsMeta, + ), + ); + } + if (data.containsKey('next_refresh_at_utc')) { + context.handle( + _nextRefreshAtUtcMeta, + nextRefreshAtUtc.isAcceptableOrUnknown( + data['next_refresh_at_utc']!, + _nextRefreshAtUtcMeta, + ), + ); + } else if (isInserting) { + context.missing(_nextRefreshAtUtcMeta); + } + if (data.containsKey('last_checked_at_utc')) { + context.handle( + _lastCheckedAtUtcMeta, + lastCheckedAtUtc.isAcceptableOrUnknown( + data['last_checked_at_utc']!, + _lastCheckedAtUtcMeta, + ), + ); + } + if (data.containsKey('last_successful_sync_at_utc')) { + context.handle( + _lastSuccessfulSyncAtUtcMeta, + lastSuccessfulSyncAtUtc.isAcceptableOrUnknown( + data['last_successful_sync_at_utc']!, + _lastSuccessfulSyncAtUtcMeta, + ), + ); + } + if (data.containsKey('last_changed_at_utc')) { + context.handle( + _lastChangedAtUtcMeta, + lastChangedAtUtc.isAcceptableOrUnknown( + data['last_changed_at_utc']!, + _lastChangedAtUtcMeta, + ), + ); + } + if (data.containsKey('consecutive_failure_count')) { + context.handle( + _consecutiveFailureCountMeta, + consecutiveFailureCount.isAcceptableOrUnknown( + data['consecutive_failure_count']!, + _consecutiveFailureCountMeta, + ), + ); + } + if (data.containsKey('last_failure_code')) { + context.handle( + _lastFailureCodeMeta, + lastFailureCode.isAcceptableOrUnknown( + data['last_failure_code']!, + _lastFailureCodeMeta, + ), + ); + } + if (data.containsKey('last_failure_http_status')) { + context.handle( + _lastFailureHttpStatusMeta, + lastFailureHttpStatus.isAcceptableOrUnknown( + data['last_failure_http_status']!, + _lastFailureHttpStatusMeta, + ), + ); + } + if (data.containsKey('generation')) { + context.handle( + _generationMeta, + generation.isAcceptableOrUnknown(data['generation']!, _generationMeta), + ); + } + if (data.containsKey('parser_version')) { + context.handle( + _parserVersionMeta, + parserVersion.isAcceptableOrUnknown( + data['parser_version']!, + _parserVersionMeta, + ), + ); + } + if (data.containsKey('projection_version')) { + context.handle( + _projectionVersionMeta, + projectionVersion.isAcceptableOrUnknown( + data['projection_version']!, + _projectionVersionMeta, + ), + ); + } + if (data.containsKey('projection_range_start_utc')) { + context.handle( + _projectionRangeStartUtcMeta, + projectionRangeStartUtc.isAcceptableOrUnknown( + data['projection_range_start_utc']!, + _projectionRangeStartUtcMeta, + ), + ); + } + if (data.containsKey('projection_range_end_utc')) { + context.handle( + _projectionRangeEndUtcMeta, + projectionRangeEndUtc.isAcceptableOrUnknown( + data['projection_range_end_utc']!, + _projectionRangeEndUtcMeta, + ), + ); + } + if (data.containsKey('created_at_utc')) { + context.handle( + _createdAtUtcMeta, + createdAtUtc.isAcceptableOrUnknown( + data['created_at_utc']!, + _createdAtUtcMeta, + ), + ); + } else if (isInserting) { + context.missing(_createdAtUtcMeta); + } + if (data.containsKey('updated_at_utc')) { + context.handle( + _updatedAtUtcMeta, + updatedAtUtc.isAcceptableOrUnknown( + data['updated_at_utc']!, + _updatedAtUtcMeta, + ), + ); + } else if (isInserting) { + context.missing(_updatedAtUtcMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {accountId}, + {calendarSourceId}, + ]; + @override + WebCalSubscription map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return WebCalSubscription( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + accountId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}account_id'], + )!, + calendarSourceId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}calendar_source_id'], + )!, + feedFingerprint: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}feed_fingerprint'], + )!, + safeOrigin: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}safe_origin'], + )!, + validatorTargetFingerprint: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}validator_target_fingerprint'], + ), + etag: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}etag'], + ), + lastModified: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_modified'], + ), + contentType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}content_type'], + ), + snapshotIcsBody: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}snapshot_ics_body'], + )!, + rawBodyHash: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}raw_body_hash'], + )!, + semanticHash: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}semantic_hash'], + )!, + refreshMode: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}refresh_mode'], + )!, + serverRefreshIntervalSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}server_refresh_interval_seconds'], + ), + nextRefreshAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}next_refresh_at_utc'], + )!, + lastCheckedAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_checked_at_utc'], + ), + lastSuccessfulSyncAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_successful_sync_at_utc'], + ), + lastChangedAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_changed_at_utc'], + ), + consecutiveFailureCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}consecutive_failure_count'], + )!, + lastFailureCode: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_failure_code'], + ), + lastFailureHttpStatus: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}last_failure_http_status'], + ), + generation: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}generation'], + )!, + parserVersion: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}parser_version'], + )!, + projectionVersion: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}projection_version'], + )!, + projectionRangeStartUtc: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}projection_range_start_utc'], + ), + projectionRangeEndUtc: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}projection_range_end_utc'], + ), + createdAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}created_at_utc'], + )!, + updatedAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}updated_at_utc'], + )!, + ); + } + + @override + $WebCalSubscriptionsTable createAlias(String alias) { + return $WebCalSubscriptionsTable(attachedDatabase, alias); + } +} + +class WebCalSubscription extends DataClass + implements Insertable { + final String id; + final String accountId; + final String calendarSourceId; + final String feedFingerprint; + final String safeOrigin; + final String? validatorTargetFingerprint; + final String? etag; + final String? lastModified; + final String? contentType; + final String snapshotIcsBody; + final String rawBodyHash; + final String semanticHash; + final String refreshMode; + final int? serverRefreshIntervalSeconds; + final String nextRefreshAtUtc; + final String? lastCheckedAtUtc; + final String? lastSuccessfulSyncAtUtc; + final String? lastChangedAtUtc; + final int consecutiveFailureCount; + final String? lastFailureCode; + final int? lastFailureHttpStatus; + final int generation; + final int parserVersion; + final int projectionVersion; + final String? projectionRangeStartUtc; + final String? projectionRangeEndUtc; + final String createdAtUtc; + final String updatedAtUtc; + const WebCalSubscription({ + required this.id, + required this.accountId, + required this.calendarSourceId, + required this.feedFingerprint, + required this.safeOrigin, + this.validatorTargetFingerprint, + this.etag, + this.lastModified, + this.contentType, + required this.snapshotIcsBody, + required this.rawBodyHash, + required this.semanticHash, + required this.refreshMode, + this.serverRefreshIntervalSeconds, + required this.nextRefreshAtUtc, + this.lastCheckedAtUtc, + this.lastSuccessfulSyncAtUtc, + this.lastChangedAtUtc, + required this.consecutiveFailureCount, + this.lastFailureCode, + this.lastFailureHttpStatus, + required this.generation, + required this.parserVersion, + required this.projectionVersion, + this.projectionRangeStartUtc, + this.projectionRangeEndUtc, + required this.createdAtUtc, + required this.updatedAtUtc, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['account_id'] = Variable(accountId); + map['calendar_source_id'] = Variable(calendarSourceId); + map['feed_fingerprint'] = Variable(feedFingerprint); + map['safe_origin'] = Variable(safeOrigin); + if (!nullToAbsent || validatorTargetFingerprint != null) { + map['validator_target_fingerprint'] = Variable( + validatorTargetFingerprint, + ); + } + if (!nullToAbsent || etag != null) { + map['etag'] = Variable(etag); + } + if (!nullToAbsent || lastModified != null) { + map['last_modified'] = Variable(lastModified); + } + if (!nullToAbsent || contentType != null) { + map['content_type'] = Variable(contentType); } + map['snapshot_ics_body'] = Variable(snapshotIcsBody); + map['raw_body_hash'] = Variable(rawBodyHash); + map['semantic_hash'] = Variable(semanticHash); + map['refresh_mode'] = Variable(refreshMode); + if (!nullToAbsent || serverRefreshIntervalSeconds != null) { + map['server_refresh_interval_seconds'] = Variable( + serverRefreshIntervalSeconds, + ); + } + map['next_refresh_at_utc'] = Variable(nextRefreshAtUtc); + if (!nullToAbsent || lastCheckedAtUtc != null) { + map['last_checked_at_utc'] = Variable(lastCheckedAtUtc); + } + if (!nullToAbsent || lastSuccessfulSyncAtUtc != null) { + map['last_successful_sync_at_utc'] = Variable( + lastSuccessfulSyncAtUtc, + ); + } + if (!nullToAbsent || lastChangedAtUtc != null) { + map['last_changed_at_utc'] = Variable(lastChangedAtUtc); + } + map['consecutive_failure_count'] = Variable(consecutiveFailureCount); + if (!nullToAbsent || lastFailureCode != null) { + map['last_failure_code'] = Variable(lastFailureCode); + } + if (!nullToAbsent || lastFailureHttpStatus != null) { + map['last_failure_http_status'] = Variable(lastFailureHttpStatus); + } + map['generation'] = Variable(generation); + map['parser_version'] = Variable(parserVersion); + map['projection_version'] = Variable(projectionVersion); + if (!nullToAbsent || projectionRangeStartUtc != null) { + map['projection_range_start_utc'] = Variable( + projectionRangeStartUtc, + ); + } + if (!nullToAbsent || projectionRangeEndUtc != null) { + map['projection_range_end_utc'] = Variable(projectionRangeEndUtc); + } + map['created_at_utc'] = Variable(createdAtUtc); + map['updated_at_utc'] = Variable(updatedAtUtc); return map; } - CalendarEventRemindersCompanion toCompanion(bool nullToAbsent) { - return CalendarEventRemindersCompanion( + WebCalSubscriptionsCompanion toCompanion(bool nullToAbsent) { + return WebCalSubscriptionsCompanion( id: Value(id), - calendarEventId: Value(calendarEventId), - provider: Value(provider), - method: method == null && nullToAbsent + accountId: Value(accountId), + calendarSourceId: Value(calendarSourceId), + feedFingerprint: Value(feedFingerprint), + safeOrigin: Value(safeOrigin), + validatorTargetFingerprint: + validatorTargetFingerprint == null && nullToAbsent ? const Value.absent() - : Value(method), - minutesBefore: minutesBefore == null && nullToAbsent + : Value(validatorTargetFingerprint), + etag: etag == null && nullToAbsent ? const Value.absent() : Value(etag), + lastModified: lastModified == null && nullToAbsent ? const Value.absent() - : Value(minutesBefore), - absoluteTime: absoluteTime == null && nullToAbsent + : Value(lastModified), + contentType: contentType == null && nullToAbsent ? const Value.absent() - : Value(absoluteTime), - enabled: Value(enabled), - rawJson: rawJson == null && nullToAbsent + : Value(contentType), + snapshotIcsBody: Value(snapshotIcsBody), + rawBodyHash: Value(rawBodyHash), + semanticHash: Value(semanticHash), + refreshMode: Value(refreshMode), + serverRefreshIntervalSeconds: + serverRefreshIntervalSeconds == null && nullToAbsent ? const Value.absent() - : Value(rawJson), + : Value(serverRefreshIntervalSeconds), + nextRefreshAtUtc: Value(nextRefreshAtUtc), + lastCheckedAtUtc: lastCheckedAtUtc == null && nullToAbsent + ? const Value.absent() + : Value(lastCheckedAtUtc), + lastSuccessfulSyncAtUtc: lastSuccessfulSyncAtUtc == null && nullToAbsent + ? const Value.absent() + : Value(lastSuccessfulSyncAtUtc), + lastChangedAtUtc: lastChangedAtUtc == null && nullToAbsent + ? const Value.absent() + : Value(lastChangedAtUtc), + consecutiveFailureCount: Value(consecutiveFailureCount), + lastFailureCode: lastFailureCode == null && nullToAbsent + ? const Value.absent() + : Value(lastFailureCode), + lastFailureHttpStatus: lastFailureHttpStatus == null && nullToAbsent + ? const Value.absent() + : Value(lastFailureHttpStatus), + generation: Value(generation), + parserVersion: Value(parserVersion), + projectionVersion: Value(projectionVersion), + projectionRangeStartUtc: projectionRangeStartUtc == null && nullToAbsent + ? const Value.absent() + : Value(projectionRangeStartUtc), + projectionRangeEndUtc: projectionRangeEndUtc == null && nullToAbsent + ? const Value.absent() + : Value(projectionRangeEndUtc), + createdAtUtc: Value(createdAtUtc), + updatedAtUtc: Value(updatedAtUtc), ); } - factory CalendarEventReminder.fromJson( + factory WebCalSubscription.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return CalendarEventReminder( + return WebCalSubscription( id: serializer.fromJson(json['id']), - calendarEventId: serializer.fromJson(json['calendarEventId']), - provider: serializer.fromJson(json['provider']), - method: serializer.fromJson(json['method']), - minutesBefore: serializer.fromJson(json['minutesBefore']), - absoluteTime: serializer.fromJson(json['absoluteTime']), - enabled: serializer.fromJson(json['enabled']), - rawJson: serializer.fromJson(json['rawJson']), + accountId: serializer.fromJson(json['accountId']), + calendarSourceId: serializer.fromJson(json['calendarSourceId']), + feedFingerprint: serializer.fromJson(json['feedFingerprint']), + safeOrigin: serializer.fromJson(json['safeOrigin']), + validatorTargetFingerprint: serializer.fromJson( + json['validatorTargetFingerprint'], + ), + etag: serializer.fromJson(json['etag']), + lastModified: serializer.fromJson(json['lastModified']), + contentType: serializer.fromJson(json['contentType']), + snapshotIcsBody: serializer.fromJson(json['snapshotIcsBody']), + rawBodyHash: serializer.fromJson(json['rawBodyHash']), + semanticHash: serializer.fromJson(json['semanticHash']), + refreshMode: serializer.fromJson(json['refreshMode']), + serverRefreshIntervalSeconds: serializer.fromJson( + json['serverRefreshIntervalSeconds'], + ), + nextRefreshAtUtc: serializer.fromJson(json['nextRefreshAtUtc']), + lastCheckedAtUtc: serializer.fromJson(json['lastCheckedAtUtc']), + lastSuccessfulSyncAtUtc: serializer.fromJson( + json['lastSuccessfulSyncAtUtc'], + ), + lastChangedAtUtc: serializer.fromJson(json['lastChangedAtUtc']), + consecutiveFailureCount: serializer.fromJson( + json['consecutiveFailureCount'], + ), + lastFailureCode: serializer.fromJson(json['lastFailureCode']), + lastFailureHttpStatus: serializer.fromJson( + json['lastFailureHttpStatus'], + ), + generation: serializer.fromJson(json['generation']), + parserVersion: serializer.fromJson(json['parserVersion']), + projectionVersion: serializer.fromJson(json['projectionVersion']), + projectionRangeStartUtc: serializer.fromJson( + json['projectionRangeStartUtc'], + ), + projectionRangeEndUtc: serializer.fromJson( + json['projectionRangeEndUtc'], + ), + createdAtUtc: serializer.fromJson(json['createdAtUtc']), + updatedAtUtc: serializer.fromJson(json['updatedAtUtc']), ); } @override @@ -19683,177 +20273,553 @@ class CalendarEventReminder extends DataClass serializer ??= driftRuntimeOptions.defaultSerializer; return { 'id': serializer.toJson(id), - 'calendarEventId': serializer.toJson(calendarEventId), - 'provider': serializer.toJson(provider), - 'method': serializer.toJson(method), - 'minutesBefore': serializer.toJson(minutesBefore), - 'absoluteTime': serializer.toJson(absoluteTime), - 'enabled': serializer.toJson(enabled), - 'rawJson': serializer.toJson(rawJson), + 'accountId': serializer.toJson(accountId), + 'calendarSourceId': serializer.toJson(calendarSourceId), + 'feedFingerprint': serializer.toJson(feedFingerprint), + 'safeOrigin': serializer.toJson(safeOrigin), + 'validatorTargetFingerprint': serializer.toJson( + validatorTargetFingerprint, + ), + 'etag': serializer.toJson(etag), + 'lastModified': serializer.toJson(lastModified), + 'contentType': serializer.toJson(contentType), + 'snapshotIcsBody': serializer.toJson(snapshotIcsBody), + 'rawBodyHash': serializer.toJson(rawBodyHash), + 'semanticHash': serializer.toJson(semanticHash), + 'refreshMode': serializer.toJson(refreshMode), + 'serverRefreshIntervalSeconds': serializer.toJson( + serverRefreshIntervalSeconds, + ), + 'nextRefreshAtUtc': serializer.toJson(nextRefreshAtUtc), + 'lastCheckedAtUtc': serializer.toJson(lastCheckedAtUtc), + 'lastSuccessfulSyncAtUtc': serializer.toJson( + lastSuccessfulSyncAtUtc, + ), + 'lastChangedAtUtc': serializer.toJson(lastChangedAtUtc), + 'consecutiveFailureCount': serializer.toJson( + consecutiveFailureCount, + ), + 'lastFailureCode': serializer.toJson(lastFailureCode), + 'lastFailureHttpStatus': serializer.toJson(lastFailureHttpStatus), + 'generation': serializer.toJson(generation), + 'parserVersion': serializer.toJson(parserVersion), + 'projectionVersion': serializer.toJson(projectionVersion), + 'projectionRangeStartUtc': serializer.toJson( + projectionRangeStartUtc, + ), + 'projectionRangeEndUtc': serializer.toJson( + projectionRangeEndUtc, + ), + 'createdAtUtc': serializer.toJson(createdAtUtc), + 'updatedAtUtc': serializer.toJson(updatedAtUtc), }; } - CalendarEventReminder copyWith({ + WebCalSubscription copyWith({ String? id, - String? calendarEventId, - String? provider, - Value method = const Value.absent(), - Value minutesBefore = const Value.absent(), - Value absoluteTime = const Value.absent(), - bool? enabled, - Value rawJson = const Value.absent(), - }) => CalendarEventReminder( + String? accountId, + String? calendarSourceId, + String? feedFingerprint, + String? safeOrigin, + Value validatorTargetFingerprint = const Value.absent(), + Value etag = const Value.absent(), + Value lastModified = const Value.absent(), + Value contentType = const Value.absent(), + String? snapshotIcsBody, + String? rawBodyHash, + String? semanticHash, + String? refreshMode, + Value serverRefreshIntervalSeconds = const Value.absent(), + String? nextRefreshAtUtc, + Value lastCheckedAtUtc = const Value.absent(), + Value lastSuccessfulSyncAtUtc = const Value.absent(), + Value lastChangedAtUtc = const Value.absent(), + int? consecutiveFailureCount, + Value lastFailureCode = const Value.absent(), + Value lastFailureHttpStatus = const Value.absent(), + int? generation, + int? parserVersion, + int? projectionVersion, + Value projectionRangeStartUtc = const Value.absent(), + Value projectionRangeEndUtc = const Value.absent(), + String? createdAtUtc, + String? updatedAtUtc, + }) => WebCalSubscription( id: id ?? this.id, - calendarEventId: calendarEventId ?? this.calendarEventId, - provider: provider ?? this.provider, - method: method.present ? method.value : this.method, - minutesBefore: minutesBefore.present - ? minutesBefore.value - : this.minutesBefore, - absoluteTime: absoluteTime.present ? absoluteTime.value : this.absoluteTime, - enabled: enabled ?? this.enabled, - rawJson: rawJson.present ? rawJson.value : this.rawJson, + accountId: accountId ?? this.accountId, + calendarSourceId: calendarSourceId ?? this.calendarSourceId, + feedFingerprint: feedFingerprint ?? this.feedFingerprint, + safeOrigin: safeOrigin ?? this.safeOrigin, + validatorTargetFingerprint: validatorTargetFingerprint.present + ? validatorTargetFingerprint.value + : this.validatorTargetFingerprint, + etag: etag.present ? etag.value : this.etag, + lastModified: lastModified.present ? lastModified.value : this.lastModified, + contentType: contentType.present ? contentType.value : this.contentType, + snapshotIcsBody: snapshotIcsBody ?? this.snapshotIcsBody, + rawBodyHash: rawBodyHash ?? this.rawBodyHash, + semanticHash: semanticHash ?? this.semanticHash, + refreshMode: refreshMode ?? this.refreshMode, + serverRefreshIntervalSeconds: serverRefreshIntervalSeconds.present + ? serverRefreshIntervalSeconds.value + : this.serverRefreshIntervalSeconds, + nextRefreshAtUtc: nextRefreshAtUtc ?? this.nextRefreshAtUtc, + lastCheckedAtUtc: lastCheckedAtUtc.present + ? lastCheckedAtUtc.value + : this.lastCheckedAtUtc, + lastSuccessfulSyncAtUtc: lastSuccessfulSyncAtUtc.present + ? lastSuccessfulSyncAtUtc.value + : this.lastSuccessfulSyncAtUtc, + lastChangedAtUtc: lastChangedAtUtc.present + ? lastChangedAtUtc.value + : this.lastChangedAtUtc, + consecutiveFailureCount: + consecutiveFailureCount ?? this.consecutiveFailureCount, + lastFailureCode: lastFailureCode.present + ? lastFailureCode.value + : this.lastFailureCode, + lastFailureHttpStatus: lastFailureHttpStatus.present + ? lastFailureHttpStatus.value + : this.lastFailureHttpStatus, + generation: generation ?? this.generation, + parserVersion: parserVersion ?? this.parserVersion, + projectionVersion: projectionVersion ?? this.projectionVersion, + projectionRangeStartUtc: projectionRangeStartUtc.present + ? projectionRangeStartUtc.value + : this.projectionRangeStartUtc, + projectionRangeEndUtc: projectionRangeEndUtc.present + ? projectionRangeEndUtc.value + : this.projectionRangeEndUtc, + createdAtUtc: createdAtUtc ?? this.createdAtUtc, + updatedAtUtc: updatedAtUtc ?? this.updatedAtUtc, ); - CalendarEventReminder copyWithCompanion( - CalendarEventRemindersCompanion data, - ) { - return CalendarEventReminder( + WebCalSubscription copyWithCompanion(WebCalSubscriptionsCompanion data) { + return WebCalSubscription( id: data.id.present ? data.id.value : this.id, - calendarEventId: data.calendarEventId.present - ? data.calendarEventId.value - : this.calendarEventId, - provider: data.provider.present ? data.provider.value : this.provider, - method: data.method.present ? data.method.value : this.method, - minutesBefore: data.minutesBefore.present - ? data.minutesBefore.value - : this.minutesBefore, - absoluteTime: data.absoluteTime.present - ? data.absoluteTime.value - : this.absoluteTime, - enabled: data.enabled.present ? data.enabled.value : this.enabled, - rawJson: data.rawJson.present ? data.rawJson.value : this.rawJson, + accountId: data.accountId.present ? data.accountId.value : this.accountId, + calendarSourceId: data.calendarSourceId.present + ? data.calendarSourceId.value + : this.calendarSourceId, + feedFingerprint: data.feedFingerprint.present + ? data.feedFingerprint.value + : this.feedFingerprint, + safeOrigin: data.safeOrigin.present + ? data.safeOrigin.value + : this.safeOrigin, + validatorTargetFingerprint: data.validatorTargetFingerprint.present + ? data.validatorTargetFingerprint.value + : this.validatorTargetFingerprint, + etag: data.etag.present ? data.etag.value : this.etag, + lastModified: data.lastModified.present + ? data.lastModified.value + : this.lastModified, + contentType: data.contentType.present + ? data.contentType.value + : this.contentType, + snapshotIcsBody: data.snapshotIcsBody.present + ? data.snapshotIcsBody.value + : this.snapshotIcsBody, + rawBodyHash: data.rawBodyHash.present + ? data.rawBodyHash.value + : this.rawBodyHash, + semanticHash: data.semanticHash.present + ? data.semanticHash.value + : this.semanticHash, + refreshMode: data.refreshMode.present + ? data.refreshMode.value + : this.refreshMode, + serverRefreshIntervalSeconds: data.serverRefreshIntervalSeconds.present + ? data.serverRefreshIntervalSeconds.value + : this.serverRefreshIntervalSeconds, + nextRefreshAtUtc: data.nextRefreshAtUtc.present + ? data.nextRefreshAtUtc.value + : this.nextRefreshAtUtc, + lastCheckedAtUtc: data.lastCheckedAtUtc.present + ? data.lastCheckedAtUtc.value + : this.lastCheckedAtUtc, + lastSuccessfulSyncAtUtc: data.lastSuccessfulSyncAtUtc.present + ? data.lastSuccessfulSyncAtUtc.value + : this.lastSuccessfulSyncAtUtc, + lastChangedAtUtc: data.lastChangedAtUtc.present + ? data.lastChangedAtUtc.value + : this.lastChangedAtUtc, + consecutiveFailureCount: data.consecutiveFailureCount.present + ? data.consecutiveFailureCount.value + : this.consecutiveFailureCount, + lastFailureCode: data.lastFailureCode.present + ? data.lastFailureCode.value + : this.lastFailureCode, + lastFailureHttpStatus: data.lastFailureHttpStatus.present + ? data.lastFailureHttpStatus.value + : this.lastFailureHttpStatus, + generation: data.generation.present + ? data.generation.value + : this.generation, + parserVersion: data.parserVersion.present + ? data.parserVersion.value + : this.parserVersion, + projectionVersion: data.projectionVersion.present + ? data.projectionVersion.value + : this.projectionVersion, + projectionRangeStartUtc: data.projectionRangeStartUtc.present + ? data.projectionRangeStartUtc.value + : this.projectionRangeStartUtc, + projectionRangeEndUtc: data.projectionRangeEndUtc.present + ? data.projectionRangeEndUtc.value + : this.projectionRangeEndUtc, + createdAtUtc: data.createdAtUtc.present + ? data.createdAtUtc.value + : this.createdAtUtc, + updatedAtUtc: data.updatedAtUtc.present + ? data.updatedAtUtc.value + : this.updatedAtUtc, ); } @override String toString() { - return (StringBuffer('CalendarEventReminder(') + return (StringBuffer('WebCalSubscription(') ..write('id: $id, ') - ..write('calendarEventId: $calendarEventId, ') - ..write('provider: $provider, ') - ..write('method: $method, ') - ..write('minutesBefore: $minutesBefore, ') - ..write('absoluteTime: $absoluteTime, ') - ..write('enabled: $enabled, ') - ..write('rawJson: $rawJson') + ..write('accountId: $accountId, ') + ..write('calendarSourceId: $calendarSourceId, ') + ..write('feedFingerprint: $feedFingerprint, ') + ..write('safeOrigin: $safeOrigin, ') + ..write('validatorTargetFingerprint: $validatorTargetFingerprint, ') + ..write('etag: $etag, ') + ..write('lastModified: $lastModified, ') + ..write('contentType: $contentType, ') + ..write('snapshotIcsBody: $snapshotIcsBody, ') + ..write('rawBodyHash: $rawBodyHash, ') + ..write('semanticHash: $semanticHash, ') + ..write('refreshMode: $refreshMode, ') + ..write( + 'serverRefreshIntervalSeconds: $serverRefreshIntervalSeconds, ', + ) + ..write('nextRefreshAtUtc: $nextRefreshAtUtc, ') + ..write('lastCheckedAtUtc: $lastCheckedAtUtc, ') + ..write('lastSuccessfulSyncAtUtc: $lastSuccessfulSyncAtUtc, ') + ..write('lastChangedAtUtc: $lastChangedAtUtc, ') + ..write('consecutiveFailureCount: $consecutiveFailureCount, ') + ..write('lastFailureCode: $lastFailureCode, ') + ..write('lastFailureHttpStatus: $lastFailureHttpStatus, ') + ..write('generation: $generation, ') + ..write('parserVersion: $parserVersion, ') + ..write('projectionVersion: $projectionVersion, ') + ..write('projectionRangeStartUtc: $projectionRangeStartUtc, ') + ..write('projectionRangeEndUtc: $projectionRangeEndUtc, ') + ..write('createdAtUtc: $createdAtUtc, ') + ..write('updatedAtUtc: $updatedAtUtc') ..write(')')) .toString(); } @override - int get hashCode => Object.hash( + int get hashCode => Object.hashAll([ id, - calendarEventId, - provider, - method, - minutesBefore, - absoluteTime, - enabled, - rawJson, - ); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is CalendarEventReminder && - other.id == this.id && - other.calendarEventId == this.calendarEventId && - other.provider == this.provider && - other.method == this.method && - other.minutesBefore == this.minutesBefore && - other.absoluteTime == this.absoluteTime && - other.enabled == this.enabled && - other.rawJson == this.rawJson); -} + accountId, + calendarSourceId, + feedFingerprint, + safeOrigin, + validatorTargetFingerprint, + etag, + lastModified, + contentType, + snapshotIcsBody, + rawBodyHash, + semanticHash, + refreshMode, + serverRefreshIntervalSeconds, + nextRefreshAtUtc, + lastCheckedAtUtc, + lastSuccessfulSyncAtUtc, + lastChangedAtUtc, + consecutiveFailureCount, + lastFailureCode, + lastFailureHttpStatus, + generation, + parserVersion, + projectionVersion, + projectionRangeStartUtc, + projectionRangeEndUtc, + createdAtUtc, + updatedAtUtc, + ]); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is WebCalSubscription && + other.id == this.id && + other.accountId == this.accountId && + other.calendarSourceId == this.calendarSourceId && + other.feedFingerprint == this.feedFingerprint && + other.safeOrigin == this.safeOrigin && + other.validatorTargetFingerprint == this.validatorTargetFingerprint && + other.etag == this.etag && + other.lastModified == this.lastModified && + other.contentType == this.contentType && + other.snapshotIcsBody == this.snapshotIcsBody && + other.rawBodyHash == this.rawBodyHash && + other.semanticHash == this.semanticHash && + other.refreshMode == this.refreshMode && + other.serverRefreshIntervalSeconds == + this.serverRefreshIntervalSeconds && + other.nextRefreshAtUtc == this.nextRefreshAtUtc && + other.lastCheckedAtUtc == this.lastCheckedAtUtc && + other.lastSuccessfulSyncAtUtc == this.lastSuccessfulSyncAtUtc && + other.lastChangedAtUtc == this.lastChangedAtUtc && + other.consecutiveFailureCount == this.consecutiveFailureCount && + other.lastFailureCode == this.lastFailureCode && + other.lastFailureHttpStatus == this.lastFailureHttpStatus && + other.generation == this.generation && + other.parserVersion == this.parserVersion && + other.projectionVersion == this.projectionVersion && + other.projectionRangeStartUtc == this.projectionRangeStartUtc && + other.projectionRangeEndUtc == this.projectionRangeEndUtc && + other.createdAtUtc == this.createdAtUtc && + other.updatedAtUtc == this.updatedAtUtc); +} -class CalendarEventRemindersCompanion - extends UpdateCompanion { +class WebCalSubscriptionsCompanion extends UpdateCompanion { final Value id; - final Value calendarEventId; - final Value provider; - final Value method; - final Value minutesBefore; - final Value absoluteTime; - final Value enabled; - final Value rawJson; + final Value accountId; + final Value calendarSourceId; + final Value feedFingerprint; + final Value safeOrigin; + final Value validatorTargetFingerprint; + final Value etag; + final Value lastModified; + final Value contentType; + final Value snapshotIcsBody; + final Value rawBodyHash; + final Value semanticHash; + final Value refreshMode; + final Value serverRefreshIntervalSeconds; + final Value nextRefreshAtUtc; + final Value lastCheckedAtUtc; + final Value lastSuccessfulSyncAtUtc; + final Value lastChangedAtUtc; + final Value consecutiveFailureCount; + final Value lastFailureCode; + final Value lastFailureHttpStatus; + final Value generation; + final Value parserVersion; + final Value projectionVersion; + final Value projectionRangeStartUtc; + final Value projectionRangeEndUtc; + final Value createdAtUtc; + final Value updatedAtUtc; final Value rowid; - const CalendarEventRemindersCompanion({ + const WebCalSubscriptionsCompanion({ this.id = const Value.absent(), - this.calendarEventId = const Value.absent(), - this.provider = const Value.absent(), - this.method = const Value.absent(), - this.minutesBefore = const Value.absent(), - this.absoluteTime = const Value.absent(), - this.enabled = const Value.absent(), - this.rawJson = const Value.absent(), + this.accountId = const Value.absent(), + this.calendarSourceId = const Value.absent(), + this.feedFingerprint = const Value.absent(), + this.safeOrigin = const Value.absent(), + this.validatorTargetFingerprint = const Value.absent(), + this.etag = const Value.absent(), + this.lastModified = const Value.absent(), + this.contentType = const Value.absent(), + this.snapshotIcsBody = const Value.absent(), + this.rawBodyHash = const Value.absent(), + this.semanticHash = const Value.absent(), + this.refreshMode = const Value.absent(), + this.serverRefreshIntervalSeconds = const Value.absent(), + this.nextRefreshAtUtc = const Value.absent(), + this.lastCheckedAtUtc = const Value.absent(), + this.lastSuccessfulSyncAtUtc = const Value.absent(), + this.lastChangedAtUtc = const Value.absent(), + this.consecutiveFailureCount = const Value.absent(), + this.lastFailureCode = const Value.absent(), + this.lastFailureHttpStatus = const Value.absent(), + this.generation = const Value.absent(), + this.parserVersion = const Value.absent(), + this.projectionVersion = const Value.absent(), + this.projectionRangeStartUtc = const Value.absent(), + this.projectionRangeEndUtc = const Value.absent(), + this.createdAtUtc = const Value.absent(), + this.updatedAtUtc = const Value.absent(), this.rowid = const Value.absent(), }); - CalendarEventRemindersCompanion.insert({ + WebCalSubscriptionsCompanion.insert({ required String id, - required String calendarEventId, - required String provider, - this.method = const Value.absent(), - this.minutesBefore = const Value.absent(), - this.absoluteTime = const Value.absent(), - this.enabled = const Value.absent(), - this.rawJson = const Value.absent(), + required String accountId, + required String calendarSourceId, + required String feedFingerprint, + required String safeOrigin, + this.validatorTargetFingerprint = const Value.absent(), + this.etag = const Value.absent(), + this.lastModified = const Value.absent(), + this.contentType = const Value.absent(), + required String snapshotIcsBody, + required String rawBodyHash, + required String semanticHash, + required String refreshMode, + this.serverRefreshIntervalSeconds = const Value.absent(), + required String nextRefreshAtUtc, + this.lastCheckedAtUtc = const Value.absent(), + this.lastSuccessfulSyncAtUtc = const Value.absent(), + this.lastChangedAtUtc = const Value.absent(), + this.consecutiveFailureCount = const Value.absent(), + this.lastFailureCode = const Value.absent(), + this.lastFailureHttpStatus = const Value.absent(), + this.generation = const Value.absent(), + this.parserVersion = const Value.absent(), + this.projectionVersion = const Value.absent(), + this.projectionRangeStartUtc = const Value.absent(), + this.projectionRangeEndUtc = const Value.absent(), + required String createdAtUtc, + required String updatedAtUtc, this.rowid = const Value.absent(), }) : id = Value(id), - calendarEventId = Value(calendarEventId), - provider = Value(provider); - static Insertable custom({ + accountId = Value(accountId), + calendarSourceId = Value(calendarSourceId), + feedFingerprint = Value(feedFingerprint), + safeOrigin = Value(safeOrigin), + snapshotIcsBody = Value(snapshotIcsBody), + rawBodyHash = Value(rawBodyHash), + semanticHash = Value(semanticHash), + refreshMode = Value(refreshMode), + nextRefreshAtUtc = Value(nextRefreshAtUtc), + createdAtUtc = Value(createdAtUtc), + updatedAtUtc = Value(updatedAtUtc); + static Insertable custom({ Expression? id, - Expression? calendarEventId, - Expression? provider, - Expression? method, - Expression? minutesBefore, - Expression? absoluteTime, - Expression? enabled, - Expression? rawJson, + Expression? accountId, + Expression? calendarSourceId, + Expression? feedFingerprint, + Expression? safeOrigin, + Expression? validatorTargetFingerprint, + Expression? etag, + Expression? lastModified, + Expression? contentType, + Expression? snapshotIcsBody, + Expression? rawBodyHash, + Expression? semanticHash, + Expression? refreshMode, + Expression? serverRefreshIntervalSeconds, + Expression? nextRefreshAtUtc, + Expression? lastCheckedAtUtc, + Expression? lastSuccessfulSyncAtUtc, + Expression? lastChangedAtUtc, + Expression? consecutiveFailureCount, + Expression? lastFailureCode, + Expression? lastFailureHttpStatus, + Expression? generation, + Expression? parserVersion, + Expression? projectionVersion, + Expression? projectionRangeStartUtc, + Expression? projectionRangeEndUtc, + Expression? createdAtUtc, + Expression? updatedAtUtc, Expression? rowid, }) { return RawValuesInsertable({ if (id != null) 'id': id, - if (calendarEventId != null) 'calendar_event_id': calendarEventId, - if (provider != null) 'provider': provider, - if (method != null) 'method': method, - if (minutesBefore != null) 'minutes_before': minutesBefore, - if (absoluteTime != null) 'absolute_time': absoluteTime, - if (enabled != null) 'enabled': enabled, - if (rawJson != null) 'raw_json': rawJson, + if (accountId != null) 'account_id': accountId, + if (calendarSourceId != null) 'calendar_source_id': calendarSourceId, + if (feedFingerprint != null) 'feed_fingerprint': feedFingerprint, + if (safeOrigin != null) 'safe_origin': safeOrigin, + if (validatorTargetFingerprint != null) + 'validator_target_fingerprint': validatorTargetFingerprint, + if (etag != null) 'etag': etag, + if (lastModified != null) 'last_modified': lastModified, + if (contentType != null) 'content_type': contentType, + if (snapshotIcsBody != null) 'snapshot_ics_body': snapshotIcsBody, + if (rawBodyHash != null) 'raw_body_hash': rawBodyHash, + if (semanticHash != null) 'semantic_hash': semanticHash, + if (refreshMode != null) 'refresh_mode': refreshMode, + if (serverRefreshIntervalSeconds != null) + 'server_refresh_interval_seconds': serverRefreshIntervalSeconds, + if (nextRefreshAtUtc != null) 'next_refresh_at_utc': nextRefreshAtUtc, + if (lastCheckedAtUtc != null) 'last_checked_at_utc': lastCheckedAtUtc, + if (lastSuccessfulSyncAtUtc != null) + 'last_successful_sync_at_utc': lastSuccessfulSyncAtUtc, + if (lastChangedAtUtc != null) 'last_changed_at_utc': lastChangedAtUtc, + if (consecutiveFailureCount != null) + 'consecutive_failure_count': consecutiveFailureCount, + if (lastFailureCode != null) 'last_failure_code': lastFailureCode, + if (lastFailureHttpStatus != null) + 'last_failure_http_status': lastFailureHttpStatus, + if (generation != null) 'generation': generation, + if (parserVersion != null) 'parser_version': parserVersion, + if (projectionVersion != null) 'projection_version': projectionVersion, + if (projectionRangeStartUtc != null) + 'projection_range_start_utc': projectionRangeStartUtc, + if (projectionRangeEndUtc != null) + 'projection_range_end_utc': projectionRangeEndUtc, + if (createdAtUtc != null) 'created_at_utc': createdAtUtc, + if (updatedAtUtc != null) 'updated_at_utc': updatedAtUtc, if (rowid != null) 'rowid': rowid, }); } - CalendarEventRemindersCompanion copyWith({ + WebCalSubscriptionsCompanion copyWith({ Value? id, - Value? calendarEventId, - Value? provider, - Value? method, - Value? minutesBefore, - Value? absoluteTime, - Value? enabled, - Value? rawJson, + Value? accountId, + Value? calendarSourceId, + Value? feedFingerprint, + Value? safeOrigin, + Value? validatorTargetFingerprint, + Value? etag, + Value? lastModified, + Value? contentType, + Value? snapshotIcsBody, + Value? rawBodyHash, + Value? semanticHash, + Value? refreshMode, + Value? serverRefreshIntervalSeconds, + Value? nextRefreshAtUtc, + Value? lastCheckedAtUtc, + Value? lastSuccessfulSyncAtUtc, + Value? lastChangedAtUtc, + Value? consecutiveFailureCount, + Value? lastFailureCode, + Value? lastFailureHttpStatus, + Value? generation, + Value? parserVersion, + Value? projectionVersion, + Value? projectionRangeStartUtc, + Value? projectionRangeEndUtc, + Value? createdAtUtc, + Value? updatedAtUtc, Value? rowid, }) { - return CalendarEventRemindersCompanion( + return WebCalSubscriptionsCompanion( id: id ?? this.id, - calendarEventId: calendarEventId ?? this.calendarEventId, - provider: provider ?? this.provider, - method: method ?? this.method, - minutesBefore: minutesBefore ?? this.minutesBefore, - absoluteTime: absoluteTime ?? this.absoluteTime, - enabled: enabled ?? this.enabled, - rawJson: rawJson ?? this.rawJson, + accountId: accountId ?? this.accountId, + calendarSourceId: calendarSourceId ?? this.calendarSourceId, + feedFingerprint: feedFingerprint ?? this.feedFingerprint, + safeOrigin: safeOrigin ?? this.safeOrigin, + validatorTargetFingerprint: + validatorTargetFingerprint ?? this.validatorTargetFingerprint, + etag: etag ?? this.etag, + lastModified: lastModified ?? this.lastModified, + contentType: contentType ?? this.contentType, + snapshotIcsBody: snapshotIcsBody ?? this.snapshotIcsBody, + rawBodyHash: rawBodyHash ?? this.rawBodyHash, + semanticHash: semanticHash ?? this.semanticHash, + refreshMode: refreshMode ?? this.refreshMode, + serverRefreshIntervalSeconds: + serverRefreshIntervalSeconds ?? this.serverRefreshIntervalSeconds, + nextRefreshAtUtc: nextRefreshAtUtc ?? this.nextRefreshAtUtc, + lastCheckedAtUtc: lastCheckedAtUtc ?? this.lastCheckedAtUtc, + lastSuccessfulSyncAtUtc: + lastSuccessfulSyncAtUtc ?? this.lastSuccessfulSyncAtUtc, + lastChangedAtUtc: lastChangedAtUtc ?? this.lastChangedAtUtc, + consecutiveFailureCount: + consecutiveFailureCount ?? this.consecutiveFailureCount, + lastFailureCode: lastFailureCode ?? this.lastFailureCode, + lastFailureHttpStatus: + lastFailureHttpStatus ?? this.lastFailureHttpStatus, + generation: generation ?? this.generation, + parserVersion: parserVersion ?? this.parserVersion, + projectionVersion: projectionVersion ?? this.projectionVersion, + projectionRangeStartUtc: + projectionRangeStartUtc ?? this.projectionRangeStartUtc, + projectionRangeEndUtc: + projectionRangeEndUtc ?? this.projectionRangeEndUtc, + createdAtUtc: createdAtUtc ?? this.createdAtUtc, + updatedAtUtc: updatedAtUtc ?? this.updatedAtUtc, rowid: rowid ?? this.rowid, ); } @@ -19864,26 +20830,100 @@ class CalendarEventRemindersCompanion if (id.present) { map['id'] = Variable(id.value); } - if (calendarEventId.present) { - map['calendar_event_id'] = Variable(calendarEventId.value); + if (accountId.present) { + map['account_id'] = Variable(accountId.value); } - if (provider.present) { - map['provider'] = Variable(provider.value); + if (calendarSourceId.present) { + map['calendar_source_id'] = Variable(calendarSourceId.value); } - if (method.present) { - map['method'] = Variable(method.value); + if (feedFingerprint.present) { + map['feed_fingerprint'] = Variable(feedFingerprint.value); } - if (minutesBefore.present) { - map['minutes_before'] = Variable(minutesBefore.value); + if (safeOrigin.present) { + map['safe_origin'] = Variable(safeOrigin.value); } - if (absoluteTime.present) { - map['absolute_time'] = Variable(absoluteTime.value); + if (validatorTargetFingerprint.present) { + map['validator_target_fingerprint'] = Variable( + validatorTargetFingerprint.value, + ); } - if (enabled.present) { - map['enabled'] = Variable(enabled.value); + if (etag.present) { + map['etag'] = Variable(etag.value); } - if (rawJson.present) { - map['raw_json'] = Variable(rawJson.value); + if (lastModified.present) { + map['last_modified'] = Variable(lastModified.value); + } + if (contentType.present) { + map['content_type'] = Variable(contentType.value); + } + if (snapshotIcsBody.present) { + map['snapshot_ics_body'] = Variable(snapshotIcsBody.value); + } + if (rawBodyHash.present) { + map['raw_body_hash'] = Variable(rawBodyHash.value); + } + if (semanticHash.present) { + map['semantic_hash'] = Variable(semanticHash.value); + } + if (refreshMode.present) { + map['refresh_mode'] = Variable(refreshMode.value); + } + if (serverRefreshIntervalSeconds.present) { + map['server_refresh_interval_seconds'] = Variable( + serverRefreshIntervalSeconds.value, + ); + } + if (nextRefreshAtUtc.present) { + map['next_refresh_at_utc'] = Variable(nextRefreshAtUtc.value); + } + if (lastCheckedAtUtc.present) { + map['last_checked_at_utc'] = Variable(lastCheckedAtUtc.value); + } + if (lastSuccessfulSyncAtUtc.present) { + map['last_successful_sync_at_utc'] = Variable( + lastSuccessfulSyncAtUtc.value, + ); + } + if (lastChangedAtUtc.present) { + map['last_changed_at_utc'] = Variable(lastChangedAtUtc.value); + } + if (consecutiveFailureCount.present) { + map['consecutive_failure_count'] = Variable( + consecutiveFailureCount.value, + ); + } + if (lastFailureCode.present) { + map['last_failure_code'] = Variable(lastFailureCode.value); + } + if (lastFailureHttpStatus.present) { + map['last_failure_http_status'] = Variable( + lastFailureHttpStatus.value, + ); + } + if (generation.present) { + map['generation'] = Variable(generation.value); + } + if (parserVersion.present) { + map['parser_version'] = Variable(parserVersion.value); + } + if (projectionVersion.present) { + map['projection_version'] = Variable(projectionVersion.value); + } + if (projectionRangeStartUtc.present) { + map['projection_range_start_utc'] = Variable( + projectionRangeStartUtc.value, + ); + } + if (projectionRangeEndUtc.present) { + map['projection_range_end_utc'] = Variable( + projectionRangeEndUtc.value, + ); + } + if (createdAtUtc.present) { + map['created_at_utc'] = Variable(createdAtUtc.value); + } + if (updatedAtUtc.present) { + map['updated_at_utc'] = Variable(updatedAtUtc.value); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -19893,27 +20933,49 @@ class CalendarEventRemindersCompanion @override String toString() { - return (StringBuffer('CalendarEventRemindersCompanion(') + return (StringBuffer('WebCalSubscriptionsCompanion(') ..write('id: $id, ') - ..write('calendarEventId: $calendarEventId, ') - ..write('provider: $provider, ') - ..write('method: $method, ') - ..write('minutesBefore: $minutesBefore, ') - ..write('absoluteTime: $absoluteTime, ') - ..write('enabled: $enabled, ') - ..write('rawJson: $rawJson, ') + ..write('accountId: $accountId, ') + ..write('calendarSourceId: $calendarSourceId, ') + ..write('feedFingerprint: $feedFingerprint, ') + ..write('safeOrigin: $safeOrigin, ') + ..write('validatorTargetFingerprint: $validatorTargetFingerprint, ') + ..write('etag: $etag, ') + ..write('lastModified: $lastModified, ') + ..write('contentType: $contentType, ') + ..write('snapshotIcsBody: $snapshotIcsBody, ') + ..write('rawBodyHash: $rawBodyHash, ') + ..write('semanticHash: $semanticHash, ') + ..write('refreshMode: $refreshMode, ') + ..write( + 'serverRefreshIntervalSeconds: $serverRefreshIntervalSeconds, ', + ) + ..write('nextRefreshAtUtc: $nextRefreshAtUtc, ') + ..write('lastCheckedAtUtc: $lastCheckedAtUtc, ') + ..write('lastSuccessfulSyncAtUtc: $lastSuccessfulSyncAtUtc, ') + ..write('lastChangedAtUtc: $lastChangedAtUtc, ') + ..write('consecutiveFailureCount: $consecutiveFailureCount, ') + ..write('lastFailureCode: $lastFailureCode, ') + ..write('lastFailureHttpStatus: $lastFailureHttpStatus, ') + ..write('generation: $generation, ') + ..write('parserVersion: $parserVersion, ') + ..write('projectionVersion: $projectionVersion, ') + ..write('projectionRangeStartUtc: $projectionRangeStartUtc, ') + ..write('projectionRangeEndUtc: $projectionRangeEndUtc, ') + ..write('createdAtUtc: $createdAtUtc, ') + ..write('updatedAtUtc: $updatedAtUtc, ') ..write('rowid: $rowid') ..write(')')) .toString(); } } -class $SyncCursorsTable extends SyncCursors - with TableInfo<$SyncCursorsTable, SyncCursor> { +class $CalendarEventAttendeesTable extends CalendarEventAttendees + with TableInfo<$CalendarEventAttendeesTable, CalendarEventAttendee> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $SyncCursorsTable(this.attachedDatabase, [this._alias]); + $CalendarEventAttendeesTable(this.attachedDatabase, [this._alias]); static const VerificationMeta _idMeta = const VerificationMeta('id'); @override late final GeneratedColumn id = GeneratedColumn( @@ -19923,195 +20985,100 @@ class $SyncCursorsTable extends SyncCursors type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _accountIdMeta = const VerificationMeta( - 'accountId', + static const VerificationMeta _calendarEventIdMeta = const VerificationMeta( + 'calendarEventId', ); @override - late final GeneratedColumn accountId = GeneratedColumn( - 'account_id', + late final GeneratedColumn calendarEventId = GeneratedColumn( + 'calendar_event_id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES accounts (id) ON DELETE CASCADE', + 'REFERENCES calendar_events (id) ON DELETE CASCADE', ), ); - static const VerificationMeta _projectionSourceIdMeta = - const VerificationMeta('projectionSourceId'); - @override - late final GeneratedColumn projectionSourceId = - GeneratedColumn( - 'projection_source_id', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES calendar_sources (id) ON DELETE CASCADE', - ), - ); - static const VerificationMeta _providerMeta = const VerificationMeta( - 'provider', - ); - @override - late final GeneratedColumn provider = GeneratedColumn( - 'provider', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _transportMeta = const VerificationMeta( - 'transport', - ); - @override - late final GeneratedColumn transport = GeneratedColumn( - 'transport', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _syncScopeKindMeta = const VerificationMeta( - 'syncScopeKind', - ); + static const VerificationMeta _emailMeta = const VerificationMeta('email'); @override - late final GeneratedColumn syncScopeKind = GeneratedColumn( - 'sync_scope_kind', + late final GeneratedColumn email = GeneratedColumn( + 'email', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _davCollectionIdMeta = const VerificationMeta( - 'davCollectionId', + static const VerificationMeta _displayNameMeta = const VerificationMeta( + 'displayName', ); @override - late final GeneratedColumn davCollectionId = GeneratedColumn( - 'dav_collection_id', + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES dav_collections (id) ON DELETE CASCADE', - ), - ); - static const VerificationMeta _cursorKindMeta = const VerificationMeta( - 'cursorKind', - ); - @override - late final GeneratedColumn cursorKind = GeneratedColumn( - 'cursor_kind', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _cursorValueMeta = const VerificationMeta( - 'cursorValue', - ); - @override - late final GeneratedColumn cursorValue = GeneratedColumn( - 'cursor_value', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, ); - static const VerificationMeta _rangeStartMeta = const VerificationMeta( - 'rangeStart', + static const VerificationMeta _responseStatusMeta = const VerificationMeta( + 'responseStatus', ); @override - late final GeneratedColumn rangeStart = GeneratedColumn( - 'range_start', + late final GeneratedColumn responseStatus = GeneratedColumn( + 'response_status', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _rangeEndMeta = const VerificationMeta( - 'rangeEnd', - ); - @override - late final GeneratedColumn rangeEnd = GeneratedColumn( - 'range_end', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, + static const VerificationMeta _optionalMeta = const VerificationMeta( + 'optional', ); - static const VerificationMeta _baselineGenerationMeta = - const VerificationMeta('baselineGeneration'); @override - late final GeneratedColumn baselineGeneration = GeneratedColumn( - 'baseline_generation', + late final GeneratedColumn optional = GeneratedColumn( + 'optional', aliasedName, false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(0), - ); - static const VerificationMeta _inProgressCursorMeta = const VerificationMeta( - 'inProgressCursor', - ); - @override - late final GeneratedColumn inProgressCursor = GeneratedColumn( - 'in_progress_cursor', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _inProgressGenerationMeta = - const VerificationMeta('inProgressGeneration'); - @override - late final GeneratedColumn inProgressGeneration = GeneratedColumn( - 'in_progress_generation', - aliasedName, - true, - type: DriftSqlType.int, - requiredDuringInsert: false, - ); - static const VerificationMeta _lastCompleteSyncAtMeta = - const VerificationMeta('lastCompleteSyncAt'); - @override - late final GeneratedColumn lastCompleteSyncAt = GeneratedColumn( - 'last_complete_sync_at', - aliasedName, - true, - type: DriftSqlType.int, + type: DriftSqlType.bool, requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("optional" IN (0, 1))', + ), + defaultValue: const Constant(false), ); - static const VerificationMeta _lastFailureCodeMeta = const VerificationMeta( - 'lastFailureCode', + static const VerificationMeta _organizerMeta = const VerificationMeta( + 'organizer', ); @override - late final GeneratedColumn lastFailureCode = GeneratedColumn( - 'last_failure_code', + late final GeneratedColumn organizer = GeneratedColumn( + 'organizer', aliasedName, - true, - type: DriftSqlType.string, + false, + type: DriftSqlType.bool, requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("organizer" IN (0, 1))', + ), + defaultValue: const Constant(false), ); - static const VerificationMeta _stateSchemaVersionMeta = - const VerificationMeta('stateSchemaVersion'); + static const VerificationMeta _selfMeta = const VerificationMeta('self'); @override - late final GeneratedColumn stateSchemaVersion = GeneratedColumn( - 'state_schema_version', + late final GeneratedColumn self = GeneratedColumn( + 'self', aliasedName, false, - type: DriftSqlType.int, + type: DriftSqlType.bool, requiredDuringInsert: false, - defaultValue: const Constant(1), + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("self" IN (0, 1))', + ), + defaultValue: const Constant(false), ); - static const VerificationMeta _stateJsonMeta = const VerificationMeta( - 'stateJson', + static const VerificationMeta _rawJsonMeta = const VerificationMeta( + 'rawJson', ); @override - late final GeneratedColumn stateJson = GeneratedColumn( - 'state_json', + late final GeneratedColumn rawJson = GeneratedColumn( + 'raw_json', aliasedName, true, type: DriftSqlType.string, @@ -20120,32 +21087,23 @@ class $SyncCursorsTable extends SyncCursors @override List get $columns => [ id, - accountId, - projectionSourceId, - provider, - transport, - syncScopeKind, - davCollectionId, - cursorKind, - cursorValue, - rangeStart, - rangeEnd, - baselineGeneration, - inProgressCursor, - inProgressGeneration, - lastCompleteSyncAt, - lastFailureCode, - stateSchemaVersion, - stateJson, + calendarEventId, + email, + displayName, + responseStatus, + optional, + organizer, + self, + rawJson, ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'sync_cursors'; + static const String $name = 'calendar_event_attendees'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); @@ -20155,148 +21113,65 @@ class $SyncCursorsTable extends SyncCursors } else if (isInserting) { context.missing(_idMeta); } - if (data.containsKey('account_id')) { + if (data.containsKey('calendar_event_id')) { context.handle( - _accountIdMeta, - accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta), + _calendarEventIdMeta, + calendarEventId.isAcceptableOrUnknown( + data['calendar_event_id']!, + _calendarEventIdMeta, + ), ); } else if (isInserting) { - context.missing(_accountIdMeta); + context.missing(_calendarEventIdMeta); } - if (data.containsKey('projection_source_id')) { - context.handle( - _projectionSourceIdMeta, - projectionSourceId.isAcceptableOrUnknown( - data['projection_source_id']!, - _projectionSourceIdMeta, - ), - ); - } - if (data.containsKey('provider')) { - context.handle( - _providerMeta, - provider.isAcceptableOrUnknown(data['provider']!, _providerMeta), - ); - } else if (isInserting) { - context.missing(_providerMeta); - } - if (data.containsKey('transport')) { - context.handle( - _transportMeta, - transport.isAcceptableOrUnknown(data['transport']!, _transportMeta), - ); - } else if (isInserting) { - context.missing(_transportMeta); - } - if (data.containsKey('sync_scope_kind')) { - context.handle( - _syncScopeKindMeta, - syncScopeKind.isAcceptableOrUnknown( - data['sync_scope_kind']!, - _syncScopeKindMeta, - ), - ); - } else if (isInserting) { - context.missing(_syncScopeKindMeta); - } - if (data.containsKey('dav_collection_id')) { - context.handle( - _davCollectionIdMeta, - davCollectionId.isAcceptableOrUnknown( - data['dav_collection_id']!, - _davCollectionIdMeta, - ), - ); - } - if (data.containsKey('cursor_kind')) { - context.handle( - _cursorKindMeta, - cursorKind.isAcceptableOrUnknown(data['cursor_kind']!, _cursorKindMeta), - ); - } else if (isInserting) { - context.missing(_cursorKindMeta); - } - if (data.containsKey('cursor_value')) { + if (data.containsKey('email')) { context.handle( - _cursorValueMeta, - cursorValue.isAcceptableOrUnknown( - data['cursor_value']!, - _cursorValueMeta, - ), + _emailMeta, + email.isAcceptableOrUnknown(data['email']!, _emailMeta), ); } else if (isInserting) { - context.missing(_cursorValueMeta); - } - if (data.containsKey('range_start')) { - context.handle( - _rangeStartMeta, - rangeStart.isAcceptableOrUnknown(data['range_start']!, _rangeStartMeta), - ); - } - if (data.containsKey('range_end')) { - context.handle( - _rangeEndMeta, - rangeEnd.isAcceptableOrUnknown(data['range_end']!, _rangeEndMeta), - ); - } - if (data.containsKey('baseline_generation')) { - context.handle( - _baselineGenerationMeta, - baselineGeneration.isAcceptableOrUnknown( - data['baseline_generation']!, - _baselineGenerationMeta, - ), - ); + context.missing(_emailMeta); } - if (data.containsKey('in_progress_cursor')) { + if (data.containsKey('display_name')) { context.handle( - _inProgressCursorMeta, - inProgressCursor.isAcceptableOrUnknown( - data['in_progress_cursor']!, - _inProgressCursorMeta, + _displayNameMeta, + displayName.isAcceptableOrUnknown( + data['display_name']!, + _displayNameMeta, ), ); } - if (data.containsKey('in_progress_generation')) { + if (data.containsKey('response_status')) { context.handle( - _inProgressGenerationMeta, - inProgressGeneration.isAcceptableOrUnknown( - data['in_progress_generation']!, - _inProgressGenerationMeta, + _responseStatusMeta, + responseStatus.isAcceptableOrUnknown( + data['response_status']!, + _responseStatusMeta, ), ); } - if (data.containsKey('last_complete_sync_at')) { + if (data.containsKey('optional')) { context.handle( - _lastCompleteSyncAtMeta, - lastCompleteSyncAt.isAcceptableOrUnknown( - data['last_complete_sync_at']!, - _lastCompleteSyncAtMeta, - ), + _optionalMeta, + optional.isAcceptableOrUnknown(data['optional']!, _optionalMeta), ); } - if (data.containsKey('last_failure_code')) { + if (data.containsKey('organizer')) { context.handle( - _lastFailureCodeMeta, - lastFailureCode.isAcceptableOrUnknown( - data['last_failure_code']!, - _lastFailureCodeMeta, - ), + _organizerMeta, + organizer.isAcceptableOrUnknown(data['organizer']!, _organizerMeta), ); } - if (data.containsKey('state_schema_version')) { + if (data.containsKey('self')) { context.handle( - _stateSchemaVersionMeta, - stateSchemaVersion.isAcceptableOrUnknown( - data['state_schema_version']!, - _stateSchemaVersionMeta, - ), + _selfMeta, + self.isAcceptableOrUnknown(data['self']!, _selfMeta), ); } - if (data.containsKey('state_json')) { + if (data.containsKey('raw_json')) { context.handle( - _stateJsonMeta, - stateJson.isAcceptableOrUnknown(data['state_json']!, _stateJsonMeta), + _rawJsonMeta, + rawJson.isAcceptableOrUnknown(data['raw_json']!, _rawJsonMeta), ); } return context; @@ -20305,240 +21180,132 @@ class $SyncCursorsTable extends SyncCursors @override Set get $primaryKey => {id}; @override - SyncCursor map(Map data, {String? tablePrefix}) { + CalendarEventAttendee map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return SyncCursor( + return CalendarEventAttendee( id: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}id'], )!, - accountId: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}account_id'], - )!, - projectionSourceId: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}projection_source_id'], - ), - provider: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}provider'], - )!, - transport: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}transport'], - )!, - syncScopeKind: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}sync_scope_kind'], - )!, - davCollectionId: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}dav_collection_id'], - ), - cursorKind: attachedDatabase.typeMapping.read( + calendarEventId: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}cursor_kind'], + data['${effectivePrefix}calendar_event_id'], )!, - cursorValue: attachedDatabase.typeMapping.read( + email: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}cursor_value'], + data['${effectivePrefix}email'], )!, - rangeStart: attachedDatabase.typeMapping.read( + displayName: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}range_start'], + data['${effectivePrefix}display_name'], ), - rangeEnd: attachedDatabase.typeMapping.read( + responseStatus: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}range_end'], + data['${effectivePrefix}response_status'], ), - baselineGeneration: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}baseline_generation'], + optional: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}optional'], )!, - inProgressCursor: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}in_progress_cursor'], - ), - inProgressGeneration: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}in_progress_generation'], - ), - lastCompleteSyncAt: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}last_complete_sync_at'], - ), - lastFailureCode: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}last_failure_code'], - ), - stateSchemaVersion: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}state_schema_version'], + organizer: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}organizer'], )!, - stateJson: attachedDatabase.typeMapping.read( + self: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}self'], + )!, + rawJson: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}state_json'], + data['${effectivePrefix}raw_json'], ), ); } @override - $SyncCursorsTable createAlias(String alias) { - return $SyncCursorsTable(attachedDatabase, alias); + $CalendarEventAttendeesTable createAlias(String alias) { + return $CalendarEventAttendeesTable(attachedDatabase, alias); } } -class SyncCursor extends DataClass implements Insertable { +class CalendarEventAttendee extends DataClass + implements Insertable { final String id; - final String accountId; - final String? projectionSourceId; - final String provider; - final String transport; - final String syncScopeKind; - final String? davCollectionId; - final String cursorKind; - final String cursorValue; - final String? rangeStart; - final String? rangeEnd; - final int baselineGeneration; - final String? inProgressCursor; - final int? inProgressGeneration; - final int? lastCompleteSyncAt; - final String? lastFailureCode; - final int stateSchemaVersion; - final String? stateJson; - const SyncCursor({ + final String calendarEventId; + final String email; + final String? displayName; + final String? responseStatus; + final bool optional; + final bool organizer; + final bool self; + final String? rawJson; + const CalendarEventAttendee({ required this.id, - required this.accountId, - this.projectionSourceId, - required this.provider, - required this.transport, - required this.syncScopeKind, - this.davCollectionId, - required this.cursorKind, - required this.cursorValue, - this.rangeStart, - this.rangeEnd, - required this.baselineGeneration, - this.inProgressCursor, - this.inProgressGeneration, - this.lastCompleteSyncAt, - this.lastFailureCode, - required this.stateSchemaVersion, - this.stateJson, + required this.calendarEventId, + required this.email, + this.displayName, + this.responseStatus, + required this.optional, + required this.organizer, + required this.self, + this.rawJson, }); @override Map toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); - map['account_id'] = Variable(accountId); - if (!nullToAbsent || projectionSourceId != null) { - map['projection_source_id'] = Variable(projectionSourceId); - } - map['provider'] = Variable(provider); - map['transport'] = Variable(transport); - map['sync_scope_kind'] = Variable(syncScopeKind); - if (!nullToAbsent || davCollectionId != null) { - map['dav_collection_id'] = Variable(davCollectionId); - } - map['cursor_kind'] = Variable(cursorKind); - map['cursor_value'] = Variable(cursorValue); - if (!nullToAbsent || rangeStart != null) { - map['range_start'] = Variable(rangeStart); - } - if (!nullToAbsent || rangeEnd != null) { - map['range_end'] = Variable(rangeEnd); - } - map['baseline_generation'] = Variable(baselineGeneration); - if (!nullToAbsent || inProgressCursor != null) { - map['in_progress_cursor'] = Variable(inProgressCursor); - } - if (!nullToAbsent || inProgressGeneration != null) { - map['in_progress_generation'] = Variable(inProgressGeneration); - } - if (!nullToAbsent || lastCompleteSyncAt != null) { - map['last_complete_sync_at'] = Variable(lastCompleteSyncAt); + map['calendar_event_id'] = Variable(calendarEventId); + map['email'] = Variable(email); + if (!nullToAbsent || displayName != null) { + map['display_name'] = Variable(displayName); } - if (!nullToAbsent || lastFailureCode != null) { - map['last_failure_code'] = Variable(lastFailureCode); + if (!nullToAbsent || responseStatus != null) { + map['response_status'] = Variable(responseStatus); } - map['state_schema_version'] = Variable(stateSchemaVersion); - if (!nullToAbsent || stateJson != null) { - map['state_json'] = Variable(stateJson); + map['optional'] = Variable(optional); + map['organizer'] = Variable(organizer); + map['self'] = Variable(self); + if (!nullToAbsent || rawJson != null) { + map['raw_json'] = Variable(rawJson); } return map; } - SyncCursorsCompanion toCompanion(bool nullToAbsent) { - return SyncCursorsCompanion( + CalendarEventAttendeesCompanion toCompanion(bool nullToAbsent) { + return CalendarEventAttendeesCompanion( id: Value(id), - accountId: Value(accountId), - projectionSourceId: projectionSourceId == null && nullToAbsent - ? const Value.absent() - : Value(projectionSourceId), - provider: Value(provider), - transport: Value(transport), - syncScopeKind: Value(syncScopeKind), - davCollectionId: davCollectionId == null && nullToAbsent - ? const Value.absent() - : Value(davCollectionId), - cursorKind: Value(cursorKind), - cursorValue: Value(cursorValue), - rangeStart: rangeStart == null && nullToAbsent - ? const Value.absent() - : Value(rangeStart), - rangeEnd: rangeEnd == null && nullToAbsent - ? const Value.absent() - : Value(rangeEnd), - baselineGeneration: Value(baselineGeneration), - inProgressCursor: inProgressCursor == null && nullToAbsent - ? const Value.absent() - : Value(inProgressCursor), - inProgressGeneration: inProgressGeneration == null && nullToAbsent - ? const Value.absent() - : Value(inProgressGeneration), - lastCompleteSyncAt: lastCompleteSyncAt == null && nullToAbsent + calendarEventId: Value(calendarEventId), + email: Value(email), + displayName: displayName == null && nullToAbsent ? const Value.absent() - : Value(lastCompleteSyncAt), - lastFailureCode: lastFailureCode == null && nullToAbsent + : Value(displayName), + responseStatus: responseStatus == null && nullToAbsent ? const Value.absent() - : Value(lastFailureCode), - stateSchemaVersion: Value(stateSchemaVersion), - stateJson: stateJson == null && nullToAbsent + : Value(responseStatus), + optional: Value(optional), + organizer: Value(organizer), + self: Value(self), + rawJson: rawJson == null && nullToAbsent ? const Value.absent() - : Value(stateJson), + : Value(rawJson), ); } - factory SyncCursor.fromJson( + factory CalendarEventAttendee.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return SyncCursor( + return CalendarEventAttendee( id: serializer.fromJson(json['id']), - accountId: serializer.fromJson(json['accountId']), - projectionSourceId: serializer.fromJson( - json['projectionSourceId'], - ), - provider: serializer.fromJson(json['provider']), - transport: serializer.fromJson(json['transport']), - syncScopeKind: serializer.fromJson(json['syncScopeKind']), - davCollectionId: serializer.fromJson(json['davCollectionId']), - cursorKind: serializer.fromJson(json['cursorKind']), - cursorValue: serializer.fromJson(json['cursorValue']), - rangeStart: serializer.fromJson(json['rangeStart']), - rangeEnd: serializer.fromJson(json['rangeEnd']), - baselineGeneration: serializer.fromJson(json['baselineGeneration']), - inProgressCursor: serializer.fromJson(json['inProgressCursor']), - inProgressGeneration: serializer.fromJson( - json['inProgressGeneration'], - ), - lastCompleteSyncAt: serializer.fromJson(json['lastCompleteSyncAt']), - lastFailureCode: serializer.fromJson(json['lastFailureCode']), - stateSchemaVersion: serializer.fromJson(json['stateSchemaVersion']), - stateJson: serializer.fromJson(json['stateJson']), + calendarEventId: serializer.fromJson(json['calendarEventId']), + email: serializer.fromJson(json['email']), + displayName: serializer.fromJson(json['displayName']), + responseStatus: serializer.fromJson(json['responseStatus']), + optional: serializer.fromJson(json['optional']), + organizer: serializer.fromJson(json['organizer']), + self: serializer.fromJson(json['self']), + rawJson: serializer.fromJson(json['rawJson']), ); } @override @@ -20546,145 +21313,74 @@ class SyncCursor extends DataClass implements Insertable { serializer ??= driftRuntimeOptions.defaultSerializer; return { 'id': serializer.toJson(id), - 'accountId': serializer.toJson(accountId), - 'projectionSourceId': serializer.toJson(projectionSourceId), - 'provider': serializer.toJson(provider), - 'transport': serializer.toJson(transport), - 'syncScopeKind': serializer.toJson(syncScopeKind), - 'davCollectionId': serializer.toJson(davCollectionId), - 'cursorKind': serializer.toJson(cursorKind), - 'cursorValue': serializer.toJson(cursorValue), - 'rangeStart': serializer.toJson(rangeStart), - 'rangeEnd': serializer.toJson(rangeEnd), - 'baselineGeneration': serializer.toJson(baselineGeneration), - 'inProgressCursor': serializer.toJson(inProgressCursor), - 'inProgressGeneration': serializer.toJson(inProgressGeneration), - 'lastCompleteSyncAt': serializer.toJson(lastCompleteSyncAt), - 'lastFailureCode': serializer.toJson(lastFailureCode), - 'stateSchemaVersion': serializer.toJson(stateSchemaVersion), - 'stateJson': serializer.toJson(stateJson), + 'calendarEventId': serializer.toJson(calendarEventId), + 'email': serializer.toJson(email), + 'displayName': serializer.toJson(displayName), + 'responseStatus': serializer.toJson(responseStatus), + 'optional': serializer.toJson(optional), + 'organizer': serializer.toJson(organizer), + 'self': serializer.toJson(self), + 'rawJson': serializer.toJson(rawJson), }; } - SyncCursor copyWith({ + CalendarEventAttendee copyWith({ String? id, - String? accountId, - Value projectionSourceId = const Value.absent(), - String? provider, - String? transport, - String? syncScopeKind, - Value davCollectionId = const Value.absent(), - String? cursorKind, - String? cursorValue, - Value rangeStart = const Value.absent(), - Value rangeEnd = const Value.absent(), - int? baselineGeneration, - Value inProgressCursor = const Value.absent(), - Value inProgressGeneration = const Value.absent(), - Value lastCompleteSyncAt = const Value.absent(), - Value lastFailureCode = const Value.absent(), - int? stateSchemaVersion, - Value stateJson = const Value.absent(), - }) => SyncCursor( + String? calendarEventId, + String? email, + Value displayName = const Value.absent(), + Value responseStatus = const Value.absent(), + bool? optional, + bool? organizer, + bool? self, + Value rawJson = const Value.absent(), + }) => CalendarEventAttendee( id: id ?? this.id, - accountId: accountId ?? this.accountId, - projectionSourceId: projectionSourceId.present - ? projectionSourceId.value - : this.projectionSourceId, - provider: provider ?? this.provider, - transport: transport ?? this.transport, - syncScopeKind: syncScopeKind ?? this.syncScopeKind, - davCollectionId: davCollectionId.present - ? davCollectionId.value - : this.davCollectionId, - cursorKind: cursorKind ?? this.cursorKind, - cursorValue: cursorValue ?? this.cursorValue, - rangeStart: rangeStart.present ? rangeStart.value : this.rangeStart, - rangeEnd: rangeEnd.present ? rangeEnd.value : this.rangeEnd, - baselineGeneration: baselineGeneration ?? this.baselineGeneration, - inProgressCursor: inProgressCursor.present - ? inProgressCursor.value - : this.inProgressCursor, - inProgressGeneration: inProgressGeneration.present - ? inProgressGeneration.value - : this.inProgressGeneration, - lastCompleteSyncAt: lastCompleteSyncAt.present - ? lastCompleteSyncAt.value - : this.lastCompleteSyncAt, - lastFailureCode: lastFailureCode.present - ? lastFailureCode.value - : this.lastFailureCode, - stateSchemaVersion: stateSchemaVersion ?? this.stateSchemaVersion, - stateJson: stateJson.present ? stateJson.value : this.stateJson, + calendarEventId: calendarEventId ?? this.calendarEventId, + email: email ?? this.email, + displayName: displayName.present ? displayName.value : this.displayName, + responseStatus: responseStatus.present + ? responseStatus.value + : this.responseStatus, + optional: optional ?? this.optional, + organizer: organizer ?? this.organizer, + self: self ?? this.self, + rawJson: rawJson.present ? rawJson.value : this.rawJson, ); - SyncCursor copyWithCompanion(SyncCursorsCompanion data) { - return SyncCursor( + CalendarEventAttendee copyWithCompanion( + CalendarEventAttendeesCompanion data, + ) { + return CalendarEventAttendee( id: data.id.present ? data.id.value : this.id, - accountId: data.accountId.present ? data.accountId.value : this.accountId, - projectionSourceId: data.projectionSourceId.present - ? data.projectionSourceId.value - : this.projectionSourceId, - provider: data.provider.present ? data.provider.value : this.provider, - transport: data.transport.present ? data.transport.value : this.transport, - syncScopeKind: data.syncScopeKind.present - ? data.syncScopeKind.value - : this.syncScopeKind, - davCollectionId: data.davCollectionId.present - ? data.davCollectionId.value - : this.davCollectionId, - cursorKind: data.cursorKind.present - ? data.cursorKind.value - : this.cursorKind, - cursorValue: data.cursorValue.present - ? data.cursorValue.value - : this.cursorValue, - rangeStart: data.rangeStart.present - ? data.rangeStart.value - : this.rangeStart, - rangeEnd: data.rangeEnd.present ? data.rangeEnd.value : this.rangeEnd, - baselineGeneration: data.baselineGeneration.present - ? data.baselineGeneration.value - : this.baselineGeneration, - inProgressCursor: data.inProgressCursor.present - ? data.inProgressCursor.value - : this.inProgressCursor, - inProgressGeneration: data.inProgressGeneration.present - ? data.inProgressGeneration.value - : this.inProgressGeneration, - lastCompleteSyncAt: data.lastCompleteSyncAt.present - ? data.lastCompleteSyncAt.value - : this.lastCompleteSyncAt, - lastFailureCode: data.lastFailureCode.present - ? data.lastFailureCode.value - : this.lastFailureCode, - stateSchemaVersion: data.stateSchemaVersion.present - ? data.stateSchemaVersion.value - : this.stateSchemaVersion, - stateJson: data.stateJson.present ? data.stateJson.value : this.stateJson, + calendarEventId: data.calendarEventId.present + ? data.calendarEventId.value + : this.calendarEventId, + email: data.email.present ? data.email.value : this.email, + displayName: data.displayName.present + ? data.displayName.value + : this.displayName, + responseStatus: data.responseStatus.present + ? data.responseStatus.value + : this.responseStatus, + optional: data.optional.present ? data.optional.value : this.optional, + organizer: data.organizer.present ? data.organizer.value : this.organizer, + self: data.self.present ? data.self.value : this.self, + rawJson: data.rawJson.present ? data.rawJson.value : this.rawJson, ); } @override String toString() { - return (StringBuffer('SyncCursor(') + return (StringBuffer('CalendarEventAttendee(') ..write('id: $id, ') - ..write('accountId: $accountId, ') - ..write('projectionSourceId: $projectionSourceId, ') - ..write('provider: $provider, ') - ..write('transport: $transport, ') - ..write('syncScopeKind: $syncScopeKind, ') - ..write('davCollectionId: $davCollectionId, ') - ..write('cursorKind: $cursorKind, ') - ..write('cursorValue: $cursorValue, ') - ..write('rangeStart: $rangeStart, ') - ..write('rangeEnd: $rangeEnd, ') - ..write('baselineGeneration: $baselineGeneration, ') - ..write('inProgressCursor: $inProgressCursor, ') - ..write('inProgressGeneration: $inProgressGeneration, ') - ..write('lastCompleteSyncAt: $lastCompleteSyncAt, ') - ..write('lastFailureCode: $lastFailureCode, ') - ..write('stateSchemaVersion: $stateSchemaVersion, ') - ..write('stateJson: $stateJson') + ..write('calendarEventId: $calendarEventId, ') + ..write('email: $email, ') + ..write('displayName: $displayName, ') + ..write('responseStatus: $responseStatus, ') + ..write('optional: $optional, ') + ..write('organizer: $organizer, ') + ..write('self: $self, ') + ..write('rawJson: $rawJson') ..write(')')) .toString(); } @@ -20692,204 +21388,116 @@ class SyncCursor extends DataClass implements Insertable { @override int get hashCode => Object.hash( id, - accountId, - projectionSourceId, - provider, - transport, - syncScopeKind, - davCollectionId, - cursorKind, - cursorValue, - rangeStart, - rangeEnd, - baselineGeneration, - inProgressCursor, - inProgressGeneration, - lastCompleteSyncAt, - lastFailureCode, - stateSchemaVersion, - stateJson, + calendarEventId, + email, + displayName, + responseStatus, + optional, + organizer, + self, + rawJson, ); @override bool operator ==(Object other) => identical(this, other) || - (other is SyncCursor && + (other is CalendarEventAttendee && other.id == this.id && - other.accountId == this.accountId && - other.projectionSourceId == this.projectionSourceId && - other.provider == this.provider && - other.transport == this.transport && - other.syncScopeKind == this.syncScopeKind && - other.davCollectionId == this.davCollectionId && - other.cursorKind == this.cursorKind && - other.cursorValue == this.cursorValue && - other.rangeStart == this.rangeStart && - other.rangeEnd == this.rangeEnd && - other.baselineGeneration == this.baselineGeneration && - other.inProgressCursor == this.inProgressCursor && - other.inProgressGeneration == this.inProgressGeneration && - other.lastCompleteSyncAt == this.lastCompleteSyncAt && - other.lastFailureCode == this.lastFailureCode && - other.stateSchemaVersion == this.stateSchemaVersion && - other.stateJson == this.stateJson); + other.calendarEventId == this.calendarEventId && + other.email == this.email && + other.displayName == this.displayName && + other.responseStatus == this.responseStatus && + other.optional == this.optional && + other.organizer == this.organizer && + other.self == this.self && + other.rawJson == this.rawJson); } -class SyncCursorsCompanion extends UpdateCompanion { +class CalendarEventAttendeesCompanion + extends UpdateCompanion { final Value id; - final Value accountId; - final Value projectionSourceId; - final Value provider; - final Value transport; - final Value syncScopeKind; - final Value davCollectionId; - final Value cursorKind; - final Value cursorValue; - final Value rangeStart; - final Value rangeEnd; - final Value baselineGeneration; - final Value inProgressCursor; - final Value inProgressGeneration; - final Value lastCompleteSyncAt; - final Value lastFailureCode; - final Value stateSchemaVersion; - final Value stateJson; + final Value calendarEventId; + final Value email; + final Value displayName; + final Value responseStatus; + final Value optional; + final Value organizer; + final Value self; + final Value rawJson; final Value rowid; - const SyncCursorsCompanion({ + const CalendarEventAttendeesCompanion({ this.id = const Value.absent(), - this.accountId = const Value.absent(), - this.projectionSourceId = const Value.absent(), - this.provider = const Value.absent(), - this.transport = const Value.absent(), - this.syncScopeKind = const Value.absent(), - this.davCollectionId = const Value.absent(), - this.cursorKind = const Value.absent(), - this.cursorValue = const Value.absent(), - this.rangeStart = const Value.absent(), - this.rangeEnd = const Value.absent(), - this.baselineGeneration = const Value.absent(), - this.inProgressCursor = const Value.absent(), - this.inProgressGeneration = const Value.absent(), - this.lastCompleteSyncAt = const Value.absent(), - this.lastFailureCode = const Value.absent(), - this.stateSchemaVersion = const Value.absent(), - this.stateJson = const Value.absent(), + this.calendarEventId = const Value.absent(), + this.email = const Value.absent(), + this.displayName = const Value.absent(), + this.responseStatus = const Value.absent(), + this.optional = const Value.absent(), + this.organizer = const Value.absent(), + this.self = const Value.absent(), + this.rawJson = const Value.absent(), this.rowid = const Value.absent(), }); - SyncCursorsCompanion.insert({ + CalendarEventAttendeesCompanion.insert({ required String id, - required String accountId, - this.projectionSourceId = const Value.absent(), - required String provider, - required String transport, - required String syncScopeKind, - this.davCollectionId = const Value.absent(), - required String cursorKind, - required String cursorValue, - this.rangeStart = const Value.absent(), - this.rangeEnd = const Value.absent(), - this.baselineGeneration = const Value.absent(), - this.inProgressCursor = const Value.absent(), - this.inProgressGeneration = const Value.absent(), - this.lastCompleteSyncAt = const Value.absent(), - this.lastFailureCode = const Value.absent(), - this.stateSchemaVersion = const Value.absent(), - this.stateJson = const Value.absent(), + required String calendarEventId, + required String email, + this.displayName = const Value.absent(), + this.responseStatus = const Value.absent(), + this.optional = const Value.absent(), + this.organizer = const Value.absent(), + this.self = const Value.absent(), + this.rawJson = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), - accountId = Value(accountId), - provider = Value(provider), - transport = Value(transport), - syncScopeKind = Value(syncScopeKind), - cursorKind = Value(cursorKind), - cursorValue = Value(cursorValue); - static Insertable custom({ + calendarEventId = Value(calendarEventId), + email = Value(email); + static Insertable custom({ Expression? id, - Expression? accountId, - Expression? projectionSourceId, - Expression? provider, - Expression? transport, - Expression? syncScopeKind, - Expression? davCollectionId, - Expression? cursorKind, - Expression? cursorValue, - Expression? rangeStart, - Expression? rangeEnd, - Expression? baselineGeneration, - Expression? inProgressCursor, - Expression? inProgressGeneration, - Expression? lastCompleteSyncAt, - Expression? lastFailureCode, - Expression? stateSchemaVersion, - Expression? stateJson, + Expression? calendarEventId, + Expression? email, + Expression? displayName, + Expression? responseStatus, + Expression? optional, + Expression? organizer, + Expression? self, + Expression? rawJson, Expression? rowid, }) { return RawValuesInsertable({ if (id != null) 'id': id, - if (accountId != null) 'account_id': accountId, - if (projectionSourceId != null) - 'projection_source_id': projectionSourceId, - if (provider != null) 'provider': provider, - if (transport != null) 'transport': transport, - if (syncScopeKind != null) 'sync_scope_kind': syncScopeKind, - if (davCollectionId != null) 'dav_collection_id': davCollectionId, - if (cursorKind != null) 'cursor_kind': cursorKind, - if (cursorValue != null) 'cursor_value': cursorValue, - if (rangeStart != null) 'range_start': rangeStart, - if (rangeEnd != null) 'range_end': rangeEnd, - if (baselineGeneration != null) 'baseline_generation': baselineGeneration, - if (inProgressCursor != null) 'in_progress_cursor': inProgressCursor, - if (inProgressGeneration != null) - 'in_progress_generation': inProgressGeneration, - if (lastCompleteSyncAt != null) - 'last_complete_sync_at': lastCompleteSyncAt, - if (lastFailureCode != null) 'last_failure_code': lastFailureCode, - if (stateSchemaVersion != null) - 'state_schema_version': stateSchemaVersion, - if (stateJson != null) 'state_json': stateJson, + if (calendarEventId != null) 'calendar_event_id': calendarEventId, + if (email != null) 'email': email, + if (displayName != null) 'display_name': displayName, + if (responseStatus != null) 'response_status': responseStatus, + if (optional != null) 'optional': optional, + if (organizer != null) 'organizer': organizer, + if (self != null) 'self': self, + if (rawJson != null) 'raw_json': rawJson, if (rowid != null) 'rowid': rowid, }); } - SyncCursorsCompanion copyWith({ + CalendarEventAttendeesCompanion copyWith({ Value? id, - Value? accountId, - Value? projectionSourceId, - Value? provider, - Value? transport, - Value? syncScopeKind, - Value? davCollectionId, - Value? cursorKind, - Value? cursorValue, - Value? rangeStart, - Value? rangeEnd, - Value? baselineGeneration, - Value? inProgressCursor, - Value? inProgressGeneration, - Value? lastCompleteSyncAt, - Value? lastFailureCode, - Value? stateSchemaVersion, - Value? stateJson, + Value? calendarEventId, + Value? email, + Value? displayName, + Value? responseStatus, + Value? optional, + Value? organizer, + Value? self, + Value? rawJson, Value? rowid, }) { - return SyncCursorsCompanion( + return CalendarEventAttendeesCompanion( id: id ?? this.id, - accountId: accountId ?? this.accountId, - projectionSourceId: projectionSourceId ?? this.projectionSourceId, - provider: provider ?? this.provider, - transport: transport ?? this.transport, - syncScopeKind: syncScopeKind ?? this.syncScopeKind, - davCollectionId: davCollectionId ?? this.davCollectionId, - cursorKind: cursorKind ?? this.cursorKind, - cursorValue: cursorValue ?? this.cursorValue, - rangeStart: rangeStart ?? this.rangeStart, - rangeEnd: rangeEnd ?? this.rangeEnd, - baselineGeneration: baselineGeneration ?? this.baselineGeneration, - inProgressCursor: inProgressCursor ?? this.inProgressCursor, - inProgressGeneration: inProgressGeneration ?? this.inProgressGeneration, - lastCompleteSyncAt: lastCompleteSyncAt ?? this.lastCompleteSyncAt, - lastFailureCode: lastFailureCode ?? this.lastFailureCode, - stateSchemaVersion: stateSchemaVersion ?? this.stateSchemaVersion, - stateJson: stateJson ?? this.stateJson, + calendarEventId: calendarEventId ?? this.calendarEventId, + email: email ?? this.email, + displayName: displayName ?? this.displayName, + responseStatus: responseStatus ?? this.responseStatus, + optional: optional ?? this.optional, + organizer: organizer ?? this.organizer, + self: self ?? this.self, + rawJson: rawJson ?? this.rawJson, rowid: rowid ?? this.rowid, ); } @@ -20900,56 +21508,29 @@ class SyncCursorsCompanion extends UpdateCompanion { if (id.present) { map['id'] = Variable(id.value); } - if (accountId.present) { - map['account_id'] = Variable(accountId.value); - } - if (projectionSourceId.present) { - map['projection_source_id'] = Variable(projectionSourceId.value); - } - if (provider.present) { - map['provider'] = Variable(provider.value); - } - if (transport.present) { - map['transport'] = Variable(transport.value); - } - if (syncScopeKind.present) { - map['sync_scope_kind'] = Variable(syncScopeKind.value); - } - if (davCollectionId.present) { - map['dav_collection_id'] = Variable(davCollectionId.value); - } - if (cursorKind.present) { - map['cursor_kind'] = Variable(cursorKind.value); - } - if (cursorValue.present) { - map['cursor_value'] = Variable(cursorValue.value); - } - if (rangeStart.present) { - map['range_start'] = Variable(rangeStart.value); - } - if (rangeEnd.present) { - map['range_end'] = Variable(rangeEnd.value); + if (calendarEventId.present) { + map['calendar_event_id'] = Variable(calendarEventId.value); } - if (baselineGeneration.present) { - map['baseline_generation'] = Variable(baselineGeneration.value); + if (email.present) { + map['email'] = Variable(email.value); } - if (inProgressCursor.present) { - map['in_progress_cursor'] = Variable(inProgressCursor.value); + if (displayName.present) { + map['display_name'] = Variable(displayName.value); } - if (inProgressGeneration.present) { - map['in_progress_generation'] = Variable(inProgressGeneration.value); + if (responseStatus.present) { + map['response_status'] = Variable(responseStatus.value); } - if (lastCompleteSyncAt.present) { - map['last_complete_sync_at'] = Variable(lastCompleteSyncAt.value); + if (optional.present) { + map['optional'] = Variable(optional.value); } - if (lastFailureCode.present) { - map['last_failure_code'] = Variable(lastFailureCode.value); + if (organizer.present) { + map['organizer'] = Variable(organizer.value); } - if (stateSchemaVersion.present) { - map['state_schema_version'] = Variable(stateSchemaVersion.value); + if (self.present) { + map['self'] = Variable(self.value); } - if (stateJson.present) { - map['state_json'] = Variable(stateJson.value); + if (rawJson.present) { + map['raw_json'] = Variable(rawJson.value); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -20959,92 +21540,108 @@ class SyncCursorsCompanion extends UpdateCompanion { @override String toString() { - return (StringBuffer('SyncCursorsCompanion(') + return (StringBuffer('CalendarEventAttendeesCompanion(') ..write('id: $id, ') - ..write('accountId: $accountId, ') - ..write('projectionSourceId: $projectionSourceId, ') - ..write('provider: $provider, ') - ..write('transport: $transport, ') - ..write('syncScopeKind: $syncScopeKind, ') - ..write('davCollectionId: $davCollectionId, ') - ..write('cursorKind: $cursorKind, ') - ..write('cursorValue: $cursorValue, ') - ..write('rangeStart: $rangeStart, ') - ..write('rangeEnd: $rangeEnd, ') - ..write('baselineGeneration: $baselineGeneration, ') - ..write('inProgressCursor: $inProgressCursor, ') - ..write('inProgressGeneration: $inProgressGeneration, ') - ..write('lastCompleteSyncAt: $lastCompleteSyncAt, ') - ..write('lastFailureCode: $lastFailureCode, ') - ..write('stateSchemaVersion: $stateSchemaVersion, ') - ..write('stateJson: $stateJson, ') + ..write('calendarEventId: $calendarEventId, ') + ..write('email: $email, ') + ..write('displayName: $displayName, ') + ..write('responseStatus: $responseStatus, ') + ..write('optional: $optional, ') + ..write('organizer: $organizer, ') + ..write('self: $self, ') + ..write('rawJson: $rawJson, ') ..write('rowid: $rowid') ..write(')')) .toString(); } } -class $CalendarColorsTable extends CalendarColors - with TableInfo<$CalendarColorsTable, CalendarColor> { +class $CalendarEventRemindersTable extends CalendarEventReminders + with TableInfo<$CalendarEventRemindersTable, CalendarEventReminder> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $CalendarColorsTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _providerMeta = const VerificationMeta( - 'provider', - ); + $CalendarEventRemindersTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); @override - late final GeneratedColumn provider = GeneratedColumn( - 'provider', + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _colorTypeMeta = const VerificationMeta( - 'colorType', + static const VerificationMeta _calendarEventIdMeta = const VerificationMeta( + 'calendarEventId', ); @override - late final GeneratedColumn colorType = GeneratedColumn( - 'color_type', + late final GeneratedColumn calendarEventId = GeneratedColumn( + 'calendar_event_id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES calendar_events (id) ON DELETE CASCADE', + ), ); - static const VerificationMeta _colorIdMeta = const VerificationMeta( - 'colorId', + static const VerificationMeta _providerMeta = const VerificationMeta( + 'provider', ); @override - late final GeneratedColumn colorId = GeneratedColumn( - 'color_id', + late final GeneratedColumn provider = GeneratedColumn( + 'provider', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _backgroundMeta = const VerificationMeta( - 'background', - ); + static const VerificationMeta _methodMeta = const VerificationMeta('method'); @override - late final GeneratedColumn background = GeneratedColumn( - 'background', + late final GeneratedColumn method = GeneratedColumn( + 'method', aliasedName, - false, + true, type: DriftSqlType.string, - requiredDuringInsert: true, + requiredDuringInsert: false, ); - static const VerificationMeta _foregroundMeta = const VerificationMeta( - 'foreground', + static const VerificationMeta _minutesBeforeMeta = const VerificationMeta( + 'minutesBefore', ); @override - late final GeneratedColumn foreground = GeneratedColumn( - 'foreground', + late final GeneratedColumn minutesBefore = GeneratedColumn( + 'minutes_before', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _absoluteTimeMeta = const VerificationMeta( + 'absoluteTime', + ); + @override + late final GeneratedColumn absoluteTime = GeneratedColumn( + 'absolute_time', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _enabledMeta = const VerificationMeta( + 'enabled', + ); + @override + late final GeneratedColumn enabled = GeneratedColumn( + 'enabled', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("enabled" IN (0, 1))', + ), + defaultValue: const Constant(true), + ); static const VerificationMeta _rawJsonMeta = const VerificationMeta( 'rawJson', ); @@ -21058,61 +21655,79 @@ class $CalendarColorsTable extends CalendarColors ); @override List get $columns => [ + id, + calendarEventId, provider, - colorType, - colorId, - background, - foreground, + method, + minutesBefore, + absoluteTime, + enabled, rawJson, ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'calendar_colors'; + static const String $name = 'calendar_event_reminders'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); final data = instance.toColumns(true); - if (data.containsKey('provider')) { - context.handle( - _providerMeta, - provider.isAcceptableOrUnknown(data['provider']!, _providerMeta), - ); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); } else if (isInserting) { - context.missing(_providerMeta); + context.missing(_idMeta); } - if (data.containsKey('color_type')) { + if (data.containsKey('calendar_event_id')) { context.handle( - _colorTypeMeta, - colorType.isAcceptableOrUnknown(data['color_type']!, _colorTypeMeta), + _calendarEventIdMeta, + calendarEventId.isAcceptableOrUnknown( + data['calendar_event_id']!, + _calendarEventIdMeta, + ), ); } else if (isInserting) { - context.missing(_colorTypeMeta); + context.missing(_calendarEventIdMeta); } - if (data.containsKey('color_id')) { + if (data.containsKey('provider')) { context.handle( - _colorIdMeta, - colorId.isAcceptableOrUnknown(data['color_id']!, _colorIdMeta), + _providerMeta, + provider.isAcceptableOrUnknown(data['provider']!, _providerMeta), ); } else if (isInserting) { - context.missing(_colorIdMeta); + context.missing(_providerMeta); } - if (data.containsKey('background')) { + if (data.containsKey('method')) { context.handle( - _backgroundMeta, - background.isAcceptableOrUnknown(data['background']!, _backgroundMeta), + _methodMeta, + method.isAcceptableOrUnknown(data['method']!, _methodMeta), ); - } else if (isInserting) { - context.missing(_backgroundMeta); } - if (data.containsKey('foreground')) { + if (data.containsKey('minutes_before')) { context.handle( - _foregroundMeta, - foreground.isAcceptableOrUnknown(data['foreground']!, _foregroundMeta), + _minutesBeforeMeta, + minutesBefore.isAcceptableOrUnknown( + data['minutes_before']!, + _minutesBeforeMeta, + ), + ); + } + if (data.containsKey('absolute_time')) { + context.handle( + _absoluteTimeMeta, + absoluteTime.isAcceptableOrUnknown( + data['absolute_time']!, + _absoluteTimeMeta, + ), + ); + } + if (data.containsKey('enabled')) { + context.handle( + _enabledMeta, + enabled.isAcceptableOrUnknown(data['enabled']!, _enabledMeta), ); } if (data.containsKey('raw_json')) { @@ -21125,31 +21740,39 @@ class $CalendarColorsTable extends CalendarColors } @override - Set get $primaryKey => {provider, colorType, colorId}; + Set get $primaryKey => {id}; @override - CalendarColor map(Map data, {String? tablePrefix}) { + CalendarEventReminder map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return CalendarColor( - provider: attachedDatabase.typeMapping.read( + return CalendarEventReminder( + id: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}provider'], + data['${effectivePrefix}id'], )!, - colorType: attachedDatabase.typeMapping.read( + calendarEventId: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}color_type'], + data['${effectivePrefix}calendar_event_id'], )!, - colorId: attachedDatabase.typeMapping.read( + provider: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}color_id'], + data['${effectivePrefix}provider'], )!, - background: attachedDatabase.typeMapping.read( + method: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}background'], - )!, - foreground: attachedDatabase.typeMapping.read( + data['${effectivePrefix}method'], + ), + minutesBefore: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}minutes_before'], + ), + absoluteTime: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}foreground'], + data['${effectivePrefix}absolute_time'], ), + enabled: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}enabled'], + )!, rawJson: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}raw_json'], @@ -21158,68 +21781,87 @@ class $CalendarColorsTable extends CalendarColors } @override - $CalendarColorsTable createAlias(String alias) { - return $CalendarColorsTable(attachedDatabase, alias); + $CalendarEventRemindersTable createAlias(String alias) { + return $CalendarEventRemindersTable(attachedDatabase, alias); } } -class CalendarColor extends DataClass implements Insertable { +class CalendarEventReminder extends DataClass + implements Insertable { + final String id; + final String calendarEventId; final String provider; - final String colorType; - final String colorId; - final String background; - final String? foreground; + final String? method; + final int? minutesBefore; + final String? absoluteTime; + final bool enabled; final String? rawJson; - const CalendarColor({ + const CalendarEventReminder({ + required this.id, + required this.calendarEventId, required this.provider, - required this.colorType, - required this.colorId, - required this.background, - this.foreground, + this.method, + this.minutesBefore, + this.absoluteTime, + required this.enabled, this.rawJson, }); @override Map toColumns(bool nullToAbsent) { final map = {}; + map['id'] = Variable(id); + map['calendar_event_id'] = Variable(calendarEventId); map['provider'] = Variable(provider); - map['color_type'] = Variable(colorType); - map['color_id'] = Variable(colorId); - map['background'] = Variable(background); - if (!nullToAbsent || foreground != null) { - map['foreground'] = Variable(foreground); + if (!nullToAbsent || method != null) { + map['method'] = Variable(method); + } + if (!nullToAbsent || minutesBefore != null) { + map['minutes_before'] = Variable(minutesBefore); + } + if (!nullToAbsent || absoluteTime != null) { + map['absolute_time'] = Variable(absoluteTime); } + map['enabled'] = Variable(enabled); if (!nullToAbsent || rawJson != null) { map['raw_json'] = Variable(rawJson); } return map; } - CalendarColorsCompanion toCompanion(bool nullToAbsent) { - return CalendarColorsCompanion( + CalendarEventRemindersCompanion toCompanion(bool nullToAbsent) { + return CalendarEventRemindersCompanion( + id: Value(id), + calendarEventId: Value(calendarEventId), provider: Value(provider), - colorType: Value(colorType), - colorId: Value(colorId), - background: Value(background), - foreground: foreground == null && nullToAbsent + method: method == null && nullToAbsent ? const Value.absent() - : Value(foreground), + : Value(method), + minutesBefore: minutesBefore == null && nullToAbsent + ? const Value.absent() + : Value(minutesBefore), + absoluteTime: absoluteTime == null && nullToAbsent + ? const Value.absent() + : Value(absoluteTime), + enabled: Value(enabled), rawJson: rawJson == null && nullToAbsent ? const Value.absent() : Value(rawJson), ); } - factory CalendarColor.fromJson( + factory CalendarEventReminder.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return CalendarColor( + return CalendarEventReminder( + id: serializer.fromJson(json['id']), + calendarEventId: serializer.fromJson(json['calendarEventId']), provider: serializer.fromJson(json['provider']), - colorType: serializer.fromJson(json['colorType']), - colorId: serializer.fromJson(json['colorId']), - background: serializer.fromJson(json['background']), - foreground: serializer.fromJson(json['foreground']), + method: serializer.fromJson(json['method']), + minutesBefore: serializer.fromJson(json['minutesBefore']), + absoluteTime: serializer.fromJson(json['absoluteTime']), + enabled: serializer.fromJson(json['enabled']), rawJson: serializer.fromJson(json['rawJson']), ); } @@ -21227,53 +21869,69 @@ class CalendarColor extends DataClass implements Insertable { Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { + 'id': serializer.toJson(id), + 'calendarEventId': serializer.toJson(calendarEventId), 'provider': serializer.toJson(provider), - 'colorType': serializer.toJson(colorType), - 'colorId': serializer.toJson(colorId), - 'background': serializer.toJson(background), - 'foreground': serializer.toJson(foreground), + 'method': serializer.toJson(method), + 'minutesBefore': serializer.toJson(minutesBefore), + 'absoluteTime': serializer.toJson(absoluteTime), + 'enabled': serializer.toJson(enabled), 'rawJson': serializer.toJson(rawJson), }; } - CalendarColor copyWith({ + CalendarEventReminder copyWith({ + String? id, + String? calendarEventId, String? provider, - String? colorType, - String? colorId, - String? background, - Value foreground = const Value.absent(), + Value method = const Value.absent(), + Value minutesBefore = const Value.absent(), + Value absoluteTime = const Value.absent(), + bool? enabled, Value rawJson = const Value.absent(), - }) => CalendarColor( + }) => CalendarEventReminder( + id: id ?? this.id, + calendarEventId: calendarEventId ?? this.calendarEventId, provider: provider ?? this.provider, - colorType: colorType ?? this.colorType, - colorId: colorId ?? this.colorId, - background: background ?? this.background, - foreground: foreground.present ? foreground.value : this.foreground, + method: method.present ? method.value : this.method, + minutesBefore: minutesBefore.present + ? minutesBefore.value + : this.minutesBefore, + absoluteTime: absoluteTime.present ? absoluteTime.value : this.absoluteTime, + enabled: enabled ?? this.enabled, rawJson: rawJson.present ? rawJson.value : this.rawJson, ); - CalendarColor copyWithCompanion(CalendarColorsCompanion data) { - return CalendarColor( + CalendarEventReminder copyWithCompanion( + CalendarEventRemindersCompanion data, + ) { + return CalendarEventReminder( + id: data.id.present ? data.id.value : this.id, + calendarEventId: data.calendarEventId.present + ? data.calendarEventId.value + : this.calendarEventId, provider: data.provider.present ? data.provider.value : this.provider, - colorType: data.colorType.present ? data.colorType.value : this.colorType, - colorId: data.colorId.present ? data.colorId.value : this.colorId, - background: data.background.present - ? data.background.value - : this.background, - foreground: data.foreground.present - ? data.foreground.value - : this.foreground, + method: data.method.present ? data.method.value : this.method, + minutesBefore: data.minutesBefore.present + ? data.minutesBefore.value + : this.minutesBefore, + absoluteTime: data.absoluteTime.present + ? data.absoluteTime.value + : this.absoluteTime, + enabled: data.enabled.present ? data.enabled.value : this.enabled, rawJson: data.rawJson.present ? data.rawJson.value : this.rawJson, ); } @override String toString() { - return (StringBuffer('CalendarColor(') + return (StringBuffer('CalendarEventReminder(') + ..write('id: $id, ') + ..write('calendarEventId: $calendarEventId, ') ..write('provider: $provider, ') - ..write('colorType: $colorType, ') - ..write('colorId: $colorId, ') - ..write('background: $background, ') - ..write('foreground: $foreground, ') + ..write('method: $method, ') + ..write('minutesBefore: $minutesBefore, ') + ..write('absoluteTime: $absoluteTime, ') + ..write('enabled: $enabled, ') ..write('rawJson: $rawJson') ..write(')')) .toString(); @@ -21281,89 +21939,107 @@ class CalendarColor extends DataClass implements Insertable { @override int get hashCode => Object.hash( + id, + calendarEventId, provider, - colorType, - colorId, - background, - foreground, + method, + minutesBefore, + absoluteTime, + enabled, rawJson, ); @override bool operator ==(Object other) => identical(this, other) || - (other is CalendarColor && + (other is CalendarEventReminder && + other.id == this.id && + other.calendarEventId == this.calendarEventId && other.provider == this.provider && - other.colorType == this.colorType && - other.colorId == this.colorId && - other.background == this.background && - other.foreground == this.foreground && + other.method == this.method && + other.minutesBefore == this.minutesBefore && + other.absoluteTime == this.absoluteTime && + other.enabled == this.enabled && other.rawJson == this.rawJson); } -class CalendarColorsCompanion extends UpdateCompanion { +class CalendarEventRemindersCompanion + extends UpdateCompanion { + final Value id; + final Value calendarEventId; final Value provider; - final Value colorType; - final Value colorId; - final Value background; - final Value foreground; + final Value method; + final Value minutesBefore; + final Value absoluteTime; + final Value enabled; final Value rawJson; final Value rowid; - const CalendarColorsCompanion({ + const CalendarEventRemindersCompanion({ + this.id = const Value.absent(), + this.calendarEventId = const Value.absent(), this.provider = const Value.absent(), - this.colorType = const Value.absent(), - this.colorId = const Value.absent(), - this.background = const Value.absent(), - this.foreground = const Value.absent(), + this.method = const Value.absent(), + this.minutesBefore = const Value.absent(), + this.absoluteTime = const Value.absent(), + this.enabled = const Value.absent(), this.rawJson = const Value.absent(), this.rowid = const Value.absent(), }); - CalendarColorsCompanion.insert({ + CalendarEventRemindersCompanion.insert({ + required String id, + required String calendarEventId, required String provider, - required String colorType, - required String colorId, - required String background, - this.foreground = const Value.absent(), + this.method = const Value.absent(), + this.minutesBefore = const Value.absent(), + this.absoluteTime = const Value.absent(), + this.enabled = const Value.absent(), this.rawJson = const Value.absent(), this.rowid = const Value.absent(), - }) : provider = Value(provider), - colorType = Value(colorType), - colorId = Value(colorId), - background = Value(background); - static Insertable custom({ + }) : id = Value(id), + calendarEventId = Value(calendarEventId), + provider = Value(provider); + static Insertable custom({ + Expression? id, + Expression? calendarEventId, Expression? provider, - Expression? colorType, - Expression? colorId, - Expression? background, - Expression? foreground, + Expression? method, + Expression? minutesBefore, + Expression? absoluteTime, + Expression? enabled, Expression? rawJson, Expression? rowid, }) { return RawValuesInsertable({ + if (id != null) 'id': id, + if (calendarEventId != null) 'calendar_event_id': calendarEventId, if (provider != null) 'provider': provider, - if (colorType != null) 'color_type': colorType, - if (colorId != null) 'color_id': colorId, - if (background != null) 'background': background, - if (foreground != null) 'foreground': foreground, + if (method != null) 'method': method, + if (minutesBefore != null) 'minutes_before': minutesBefore, + if (absoluteTime != null) 'absolute_time': absoluteTime, + if (enabled != null) 'enabled': enabled, if (rawJson != null) 'raw_json': rawJson, if (rowid != null) 'rowid': rowid, }); } - CalendarColorsCompanion copyWith({ + CalendarEventRemindersCompanion copyWith({ + Value? id, + Value? calendarEventId, Value? provider, - Value? colorType, - Value? colorId, - Value? background, - Value? foreground, + Value? method, + Value? minutesBefore, + Value? absoluteTime, + Value? enabled, Value? rawJson, Value? rowid, }) { - return CalendarColorsCompanion( + return CalendarEventRemindersCompanion( + id: id ?? this.id, + calendarEventId: calendarEventId ?? this.calendarEventId, provider: provider ?? this.provider, - colorType: colorType ?? this.colorType, - colorId: colorId ?? this.colorId, - background: background ?? this.background, - foreground: foreground ?? this.foreground, + method: method ?? this.method, + minutesBefore: minutesBefore ?? this.minutesBefore, + absoluteTime: absoluteTime ?? this.absoluteTime, + enabled: enabled ?? this.enabled, rawJson: rawJson ?? this.rawJson, rowid: rowid ?? this.rowid, ); @@ -21372,20 +22048,26 @@ class CalendarColorsCompanion extends UpdateCompanion { @override Map toColumns(bool nullToAbsent) { final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (calendarEventId.present) { + map['calendar_event_id'] = Variable(calendarEventId.value); + } if (provider.present) { map['provider'] = Variable(provider.value); } - if (colorType.present) { - map['color_type'] = Variable(colorType.value); + if (method.present) { + map['method'] = Variable(method.value); } - if (colorId.present) { - map['color_id'] = Variable(colorId.value); + if (minutesBefore.present) { + map['minutes_before'] = Variable(minutesBefore.value); } - if (background.present) { - map['background'] = Variable(background.value); + if (absoluteTime.present) { + map['absolute_time'] = Variable(absoluteTime.value); } - if (foreground.present) { - map['foreground'] = Variable(foreground.value); + if (enabled.present) { + map['enabled'] = Variable(enabled.value); } if (rawJson.present) { map['raw_json'] = Variable(rawJson.value); @@ -21398,12 +22080,14 @@ class CalendarColorsCompanion extends UpdateCompanion { @override String toString() { - return (StringBuffer('CalendarColorsCompanion(') + return (StringBuffer('CalendarEventRemindersCompanion(') + ..write('id: $id, ') + ..write('calendarEventId: $calendarEventId, ') ..write('provider: $provider, ') - ..write('colorType: $colorType, ') - ..write('colorId: $colorId, ') - ..write('background: $background, ') - ..write('foreground: $foreground, ') + ..write('method: $method, ') + ..write('minutesBefore: $minutesBefore, ') + ..write('absoluteTime: $absoluteTime, ') + ..write('enabled: $enabled, ') ..write('rawJson: $rawJson, ') ..write('rowid: $rowid') ..write(')')) @@ -21411,12 +22095,12 @@ class CalendarColorsCompanion extends UpdateCompanion { } } -class $ScheduleItemOverridesTable extends ScheduleItemOverrides - with TableInfo<$ScheduleItemOverridesTable, ScheduleItemOverride> { +class $SyncCursorsTable extends SyncCursors + with TableInfo<$SyncCursorsTable, SyncCursor> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $ScheduleItemOverridesTable(this.attachedDatabase, [this._alias]); + $SyncCursorsTable(this.attachedDatabase, [this._alias]); static const VerificationMeta _idMeta = const VerificationMeta('id'); @override late final GeneratedColumn id = GeneratedColumn( @@ -21440,83 +22124,219 @@ class $ScheduleItemOverridesTable extends ScheduleItemOverrides 'REFERENCES accounts (id) ON DELETE CASCADE', ), ); - static const VerificationMeta _sourceTypeMeta = const VerificationMeta( - 'sourceType', + static const VerificationMeta _projectionSourceIdMeta = + const VerificationMeta('projectionSourceId'); + @override + late final GeneratedColumn projectionSourceId = + GeneratedColumn( + 'projection_source_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES calendar_sources (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _providerMeta = const VerificationMeta( + 'provider', ); @override - late final GeneratedColumn sourceType = GeneratedColumn( - 'source_type', + late final GeneratedColumn provider = GeneratedColumn( + 'provider', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _sourceIdMeta = const VerificationMeta( - 'sourceId', + static const VerificationMeta _transportMeta = const VerificationMeta( + 'transport', ); @override - late final GeneratedColumn sourceId = GeneratedColumn( - 'source_id', + late final GeneratedColumn transport = GeneratedColumn( + 'transport', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _overrideJsonMeta = const VerificationMeta( - 'overrideJson', + static const VerificationMeta _syncScopeKindMeta = const VerificationMeta( + 'syncScopeKind', ); @override - late final GeneratedColumn overrideJson = GeneratedColumn( - 'override_json', + late final GeneratedColumn syncScopeKind = GeneratedColumn( + 'sync_scope_kind', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _createdAtLocalMeta = const VerificationMeta( - 'createdAtLocal', + static const VerificationMeta _davCollectionIdMeta = const VerificationMeta( + 'davCollectionId', ); @override - late final GeneratedColumn createdAtLocal = GeneratedColumn( - 'created_at_local', + late final GeneratedColumn davCollectionId = GeneratedColumn( + 'dav_collection_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES dav_collections (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _cursorKindMeta = const VerificationMeta( + 'cursorKind', + ); + @override + late final GeneratedColumn cursorKind = GeneratedColumn( + 'cursor_kind', aliasedName, false, - type: DriftSqlType.int, + type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _updatedAtLocalMeta = const VerificationMeta( - 'updatedAtLocal', + static const VerificationMeta _cursorValueMeta = const VerificationMeta( + 'cursorValue', ); @override - late final GeneratedColumn updatedAtLocal = GeneratedColumn( - 'updated_at_local', + late final GeneratedColumn cursorValue = GeneratedColumn( + 'cursor_value', aliasedName, false, - type: DriftSqlType.int, + type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _rangeStartMeta = const VerificationMeta( + 'rangeStart', + ); @override - List get $columns => [ - id, - accountId, - sourceType, - sourceId, - overrideJson, - createdAtLocal, - updatedAtLocal, - ]; + late final GeneratedColumn rangeStart = GeneratedColumn( + 'range_start', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _rangeEndMeta = const VerificationMeta( + 'rangeEnd', + ); @override - String get aliasedName => _alias ?? actualTableName; + late final GeneratedColumn rangeEnd = GeneratedColumn( + 'range_end', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _baselineGenerationMeta = + const VerificationMeta('baselineGeneration'); @override - String get actualTableName => $name; - static const String $name = 'schedule_item_overrides'; + late final GeneratedColumn baselineGeneration = GeneratedColumn( + 'baseline_generation', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _inProgressCursorMeta = const VerificationMeta( + 'inProgressCursor', + ); @override - VerificationContext validateIntegrity( - Insertable instance, { - bool isInserting = false, - }) { - final context = VerificationContext(); - final data = instance.toColumns(true); + late final GeneratedColumn inProgressCursor = GeneratedColumn( + 'in_progress_cursor', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _inProgressGenerationMeta = + const VerificationMeta('inProgressGeneration'); + @override + late final GeneratedColumn inProgressGeneration = GeneratedColumn( + 'in_progress_generation', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastCompleteSyncAtMeta = + const VerificationMeta('lastCompleteSyncAt'); + @override + late final GeneratedColumn lastCompleteSyncAt = GeneratedColumn( + 'last_complete_sync_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastFailureCodeMeta = const VerificationMeta( + 'lastFailureCode', + ); + @override + late final GeneratedColumn lastFailureCode = GeneratedColumn( + 'last_failure_code', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _stateSchemaVersionMeta = + const VerificationMeta('stateSchemaVersion'); + @override + late final GeneratedColumn stateSchemaVersion = GeneratedColumn( + 'state_schema_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(1), + ); + static const VerificationMeta _stateJsonMeta = const VerificationMeta( + 'stateJson', + ); + @override + late final GeneratedColumn stateJson = GeneratedColumn( + 'state_json', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + accountId, + projectionSourceId, + provider, + transport, + syncScopeKind, + davCollectionId, + cursorKind, + cursorValue, + rangeStart, + rangeEnd, + baselineGeneration, + inProgressCursor, + inProgressGeneration, + lastCompleteSyncAt, + lastFailureCode, + stateSchemaVersion, + stateJson, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sync_cursors'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); if (data.containsKey('id')) { context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); } else if (isInserting) { @@ -21530,54 +22350,141 @@ class $ScheduleItemOverridesTable extends ScheduleItemOverrides } else if (isInserting) { context.missing(_accountIdMeta); } - if (data.containsKey('source_type')) { + if (data.containsKey('projection_source_id')) { context.handle( - _sourceTypeMeta, - sourceType.isAcceptableOrUnknown(data['source_type']!, _sourceTypeMeta), + _projectionSourceIdMeta, + projectionSourceId.isAcceptableOrUnknown( + data['projection_source_id']!, + _projectionSourceIdMeta, + ), + ); + } + if (data.containsKey('provider')) { + context.handle( + _providerMeta, + provider.isAcceptableOrUnknown(data['provider']!, _providerMeta), ); } else if (isInserting) { - context.missing(_sourceTypeMeta); + context.missing(_providerMeta); } - if (data.containsKey('source_id')) { + if (data.containsKey('transport')) { context.handle( - _sourceIdMeta, - sourceId.isAcceptableOrUnknown(data['source_id']!, _sourceIdMeta), + _transportMeta, + transport.isAcceptableOrUnknown(data['transport']!, _transportMeta), ); } else if (isInserting) { - context.missing(_sourceIdMeta); + context.missing(_transportMeta); } - if (data.containsKey('override_json')) { + if (data.containsKey('sync_scope_kind')) { context.handle( - _overrideJsonMeta, - overrideJson.isAcceptableOrUnknown( - data['override_json']!, - _overrideJsonMeta, + _syncScopeKindMeta, + syncScopeKind.isAcceptableOrUnknown( + data['sync_scope_kind']!, + _syncScopeKindMeta, ), ); } else if (isInserting) { - context.missing(_overrideJsonMeta); + context.missing(_syncScopeKindMeta); } - if (data.containsKey('created_at_local')) { + if (data.containsKey('dav_collection_id')) { context.handle( - _createdAtLocalMeta, - createdAtLocal.isAcceptableOrUnknown( - data['created_at_local']!, - _createdAtLocalMeta, + _davCollectionIdMeta, + davCollectionId.isAcceptableOrUnknown( + data['dav_collection_id']!, + _davCollectionIdMeta, ), ); + } + if (data.containsKey('cursor_kind')) { + context.handle( + _cursorKindMeta, + cursorKind.isAcceptableOrUnknown(data['cursor_kind']!, _cursorKindMeta), + ); } else if (isInserting) { - context.missing(_createdAtLocalMeta); + context.missing(_cursorKindMeta); } - if (data.containsKey('updated_at_local')) { + if (data.containsKey('cursor_value')) { context.handle( - _updatedAtLocalMeta, - updatedAtLocal.isAcceptableOrUnknown( - data['updated_at_local']!, - _updatedAtLocalMeta, + _cursorValueMeta, + cursorValue.isAcceptableOrUnknown( + data['cursor_value']!, + _cursorValueMeta, ), ); } else if (isInserting) { - context.missing(_updatedAtLocalMeta); + context.missing(_cursorValueMeta); + } + if (data.containsKey('range_start')) { + context.handle( + _rangeStartMeta, + rangeStart.isAcceptableOrUnknown(data['range_start']!, _rangeStartMeta), + ); + } + if (data.containsKey('range_end')) { + context.handle( + _rangeEndMeta, + rangeEnd.isAcceptableOrUnknown(data['range_end']!, _rangeEndMeta), + ); + } + if (data.containsKey('baseline_generation')) { + context.handle( + _baselineGenerationMeta, + baselineGeneration.isAcceptableOrUnknown( + data['baseline_generation']!, + _baselineGenerationMeta, + ), + ); + } + if (data.containsKey('in_progress_cursor')) { + context.handle( + _inProgressCursorMeta, + inProgressCursor.isAcceptableOrUnknown( + data['in_progress_cursor']!, + _inProgressCursorMeta, + ), + ); + } + if (data.containsKey('in_progress_generation')) { + context.handle( + _inProgressGenerationMeta, + inProgressGeneration.isAcceptableOrUnknown( + data['in_progress_generation']!, + _inProgressGenerationMeta, + ), + ); + } + if (data.containsKey('last_complete_sync_at')) { + context.handle( + _lastCompleteSyncAtMeta, + lastCompleteSyncAt.isAcceptableOrUnknown( + data['last_complete_sync_at']!, + _lastCompleteSyncAtMeta, + ), + ); + } + if (data.containsKey('last_failure_code')) { + context.handle( + _lastFailureCodeMeta, + lastFailureCode.isAcceptableOrUnknown( + data['last_failure_code']!, + _lastFailureCodeMeta, + ), + ); + } + if (data.containsKey('state_schema_version')) { + context.handle( + _stateSchemaVersionMeta, + stateSchemaVersion.isAcceptableOrUnknown( + data['state_schema_version']!, + _stateSchemaVersionMeta, + ), + ); + } + if (data.containsKey('state_json')) { + context.handle( + _stateJsonMeta, + stateJson.isAcceptableOrUnknown(data['state_json']!, _stateJsonMeta), + ); } return context; } @@ -21585,9 +22492,9 @@ class $ScheduleItemOverridesTable extends ScheduleItemOverrides @override Set get $primaryKey => {id}; @override - ScheduleItemOverride map(Map data, {String? tablePrefix}) { + SyncCursor map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return ScheduleItemOverride( + return SyncCursor( id: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}id'], @@ -21596,91 +22503,229 @@ class $ScheduleItemOverridesTable extends ScheduleItemOverrides DriftSqlType.string, data['${effectivePrefix}account_id'], )!, - sourceType: attachedDatabase.typeMapping.read( + projectionSourceId: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}source_type'], + data['${effectivePrefix}projection_source_id'], + ), + provider: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}provider'], )!, - sourceId: attachedDatabase.typeMapping.read( + transport: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}source_id'], + data['${effectivePrefix}transport'], )!, - overrideJson: attachedDatabase.typeMapping.read( + syncScopeKind: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}override_json'], + data['${effectivePrefix}sync_scope_kind'], )!, - createdAtLocal: attachedDatabase.typeMapping.read( + davCollectionId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}dav_collection_id'], + ), + cursorKind: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}cursor_kind'], + )!, + cursorValue: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}cursor_value'], + )!, + rangeStart: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}range_start'], + ), + rangeEnd: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}range_end'], + ), + baselineGeneration: attachedDatabase.typeMapping.read( DriftSqlType.int, - data['${effectivePrefix}created_at_local'], + data['${effectivePrefix}baseline_generation'], )!, - updatedAtLocal: attachedDatabase.typeMapping.read( + inProgressCursor: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}in_progress_cursor'], + ), + inProgressGeneration: attachedDatabase.typeMapping.read( DriftSqlType.int, - data['${effectivePrefix}updated_at_local'], + data['${effectivePrefix}in_progress_generation'], + ), + lastCompleteSyncAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}last_complete_sync_at'], + ), + lastFailureCode: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_failure_code'], + ), + stateSchemaVersion: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}state_schema_version'], )!, + stateJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}state_json'], + ), ); } @override - $ScheduleItemOverridesTable createAlias(String alias) { - return $ScheduleItemOverridesTable(attachedDatabase, alias); + $SyncCursorsTable createAlias(String alias) { + return $SyncCursorsTable(attachedDatabase, alias); } } -class ScheduleItemOverride extends DataClass - implements Insertable { +class SyncCursor extends DataClass implements Insertable { final String id; final String accountId; - final String sourceType; - final String sourceId; - final String overrideJson; - final int createdAtLocal; - final int updatedAtLocal; - const ScheduleItemOverride({ + final String? projectionSourceId; + final String provider; + final String transport; + final String syncScopeKind; + final String? davCollectionId; + final String cursorKind; + final String cursorValue; + final String? rangeStart; + final String? rangeEnd; + final int baselineGeneration; + final String? inProgressCursor; + final int? inProgressGeneration; + final int? lastCompleteSyncAt; + final String? lastFailureCode; + final int stateSchemaVersion; + final String? stateJson; + const SyncCursor({ required this.id, required this.accountId, - required this.sourceType, - required this.sourceId, - required this.overrideJson, - required this.createdAtLocal, - required this.updatedAtLocal, + this.projectionSourceId, + required this.provider, + required this.transport, + required this.syncScopeKind, + this.davCollectionId, + required this.cursorKind, + required this.cursorValue, + this.rangeStart, + this.rangeEnd, + required this.baselineGeneration, + this.inProgressCursor, + this.inProgressGeneration, + this.lastCompleteSyncAt, + this.lastFailureCode, + required this.stateSchemaVersion, + this.stateJson, }); @override Map toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); map['account_id'] = Variable(accountId); - map['source_type'] = Variable(sourceType); - map['source_id'] = Variable(sourceId); - map['override_json'] = Variable(overrideJson); - map['created_at_local'] = Variable(createdAtLocal); - map['updated_at_local'] = Variable(updatedAtLocal); + if (!nullToAbsent || projectionSourceId != null) { + map['projection_source_id'] = Variable(projectionSourceId); + } + map['provider'] = Variable(provider); + map['transport'] = Variable(transport); + map['sync_scope_kind'] = Variable(syncScopeKind); + if (!nullToAbsent || davCollectionId != null) { + map['dav_collection_id'] = Variable(davCollectionId); + } + map['cursor_kind'] = Variable(cursorKind); + map['cursor_value'] = Variable(cursorValue); + if (!nullToAbsent || rangeStart != null) { + map['range_start'] = Variable(rangeStart); + } + if (!nullToAbsent || rangeEnd != null) { + map['range_end'] = Variable(rangeEnd); + } + map['baseline_generation'] = Variable(baselineGeneration); + if (!nullToAbsent || inProgressCursor != null) { + map['in_progress_cursor'] = Variable(inProgressCursor); + } + if (!nullToAbsent || inProgressGeneration != null) { + map['in_progress_generation'] = Variable(inProgressGeneration); + } + if (!nullToAbsent || lastCompleteSyncAt != null) { + map['last_complete_sync_at'] = Variable(lastCompleteSyncAt); + } + if (!nullToAbsent || lastFailureCode != null) { + map['last_failure_code'] = Variable(lastFailureCode); + } + map['state_schema_version'] = Variable(stateSchemaVersion); + if (!nullToAbsent || stateJson != null) { + map['state_json'] = Variable(stateJson); + } return map; } - ScheduleItemOverridesCompanion toCompanion(bool nullToAbsent) { - return ScheduleItemOverridesCompanion( + SyncCursorsCompanion toCompanion(bool nullToAbsent) { + return SyncCursorsCompanion( id: Value(id), accountId: Value(accountId), - sourceType: Value(sourceType), - sourceId: Value(sourceId), - overrideJson: Value(overrideJson), - createdAtLocal: Value(createdAtLocal), - updatedAtLocal: Value(updatedAtLocal), + projectionSourceId: projectionSourceId == null && nullToAbsent + ? const Value.absent() + : Value(projectionSourceId), + provider: Value(provider), + transport: Value(transport), + syncScopeKind: Value(syncScopeKind), + davCollectionId: davCollectionId == null && nullToAbsent + ? const Value.absent() + : Value(davCollectionId), + cursorKind: Value(cursorKind), + cursorValue: Value(cursorValue), + rangeStart: rangeStart == null && nullToAbsent + ? const Value.absent() + : Value(rangeStart), + rangeEnd: rangeEnd == null && nullToAbsent + ? const Value.absent() + : Value(rangeEnd), + baselineGeneration: Value(baselineGeneration), + inProgressCursor: inProgressCursor == null && nullToAbsent + ? const Value.absent() + : Value(inProgressCursor), + inProgressGeneration: inProgressGeneration == null && nullToAbsent + ? const Value.absent() + : Value(inProgressGeneration), + lastCompleteSyncAt: lastCompleteSyncAt == null && nullToAbsent + ? const Value.absent() + : Value(lastCompleteSyncAt), + lastFailureCode: lastFailureCode == null && nullToAbsent + ? const Value.absent() + : Value(lastFailureCode), + stateSchemaVersion: Value(stateSchemaVersion), + stateJson: stateJson == null && nullToAbsent + ? const Value.absent() + : Value(stateJson), ); } - factory ScheduleItemOverride.fromJson( + factory SyncCursor.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return ScheduleItemOverride( + return SyncCursor( id: serializer.fromJson(json['id']), accountId: serializer.fromJson(json['accountId']), - sourceType: serializer.fromJson(json['sourceType']), - sourceId: serializer.fromJson(json['sourceId']), - overrideJson: serializer.fromJson(json['overrideJson']), - createdAtLocal: serializer.fromJson(json['createdAtLocal']), - updatedAtLocal: serializer.fromJson(json['updatedAtLocal']), + projectionSourceId: serializer.fromJson( + json['projectionSourceId'], + ), + provider: serializer.fromJson(json['provider']), + transport: serializer.fromJson(json['transport']), + syncScopeKind: serializer.fromJson(json['syncScopeKind']), + davCollectionId: serializer.fromJson(json['davCollectionId']), + cursorKind: serializer.fromJson(json['cursorKind']), + cursorValue: serializer.fromJson(json['cursorValue']), + rangeStart: serializer.fromJson(json['rangeStart']), + rangeEnd: serializer.fromJson(json['rangeEnd']), + baselineGeneration: serializer.fromJson(json['baselineGeneration']), + inProgressCursor: serializer.fromJson(json['inProgressCursor']), + inProgressGeneration: serializer.fromJson( + json['inProgressGeneration'], + ), + lastCompleteSyncAt: serializer.fromJson(json['lastCompleteSyncAt']), + lastFailureCode: serializer.fromJson(json['lastFailureCode']), + stateSchemaVersion: serializer.fromJson(json['stateSchemaVersion']), + stateJson: serializer.fromJson(json['stateJson']), ); } @override @@ -21689,61 +22734,144 @@ class ScheduleItemOverride extends DataClass return { 'id': serializer.toJson(id), 'accountId': serializer.toJson(accountId), - 'sourceType': serializer.toJson(sourceType), - 'sourceId': serializer.toJson(sourceId), - 'overrideJson': serializer.toJson(overrideJson), - 'createdAtLocal': serializer.toJson(createdAtLocal), - 'updatedAtLocal': serializer.toJson(updatedAtLocal), + 'projectionSourceId': serializer.toJson(projectionSourceId), + 'provider': serializer.toJson(provider), + 'transport': serializer.toJson(transport), + 'syncScopeKind': serializer.toJson(syncScopeKind), + 'davCollectionId': serializer.toJson(davCollectionId), + 'cursorKind': serializer.toJson(cursorKind), + 'cursorValue': serializer.toJson(cursorValue), + 'rangeStart': serializer.toJson(rangeStart), + 'rangeEnd': serializer.toJson(rangeEnd), + 'baselineGeneration': serializer.toJson(baselineGeneration), + 'inProgressCursor': serializer.toJson(inProgressCursor), + 'inProgressGeneration': serializer.toJson(inProgressGeneration), + 'lastCompleteSyncAt': serializer.toJson(lastCompleteSyncAt), + 'lastFailureCode': serializer.toJson(lastFailureCode), + 'stateSchemaVersion': serializer.toJson(stateSchemaVersion), + 'stateJson': serializer.toJson(stateJson), }; } - ScheduleItemOverride copyWith({ + SyncCursor copyWith({ String? id, String? accountId, - String? sourceType, - String? sourceId, - String? overrideJson, - int? createdAtLocal, - int? updatedAtLocal, - }) => ScheduleItemOverride( + Value projectionSourceId = const Value.absent(), + String? provider, + String? transport, + String? syncScopeKind, + Value davCollectionId = const Value.absent(), + String? cursorKind, + String? cursorValue, + Value rangeStart = const Value.absent(), + Value rangeEnd = const Value.absent(), + int? baselineGeneration, + Value inProgressCursor = const Value.absent(), + Value inProgressGeneration = const Value.absent(), + Value lastCompleteSyncAt = const Value.absent(), + Value lastFailureCode = const Value.absent(), + int? stateSchemaVersion, + Value stateJson = const Value.absent(), + }) => SyncCursor( id: id ?? this.id, accountId: accountId ?? this.accountId, - sourceType: sourceType ?? this.sourceType, - sourceId: sourceId ?? this.sourceId, - overrideJson: overrideJson ?? this.overrideJson, - createdAtLocal: createdAtLocal ?? this.createdAtLocal, - updatedAtLocal: updatedAtLocal ?? this.updatedAtLocal, + projectionSourceId: projectionSourceId.present + ? projectionSourceId.value + : this.projectionSourceId, + provider: provider ?? this.provider, + transport: transport ?? this.transport, + syncScopeKind: syncScopeKind ?? this.syncScopeKind, + davCollectionId: davCollectionId.present + ? davCollectionId.value + : this.davCollectionId, + cursorKind: cursorKind ?? this.cursorKind, + cursorValue: cursorValue ?? this.cursorValue, + rangeStart: rangeStart.present ? rangeStart.value : this.rangeStart, + rangeEnd: rangeEnd.present ? rangeEnd.value : this.rangeEnd, + baselineGeneration: baselineGeneration ?? this.baselineGeneration, + inProgressCursor: inProgressCursor.present + ? inProgressCursor.value + : this.inProgressCursor, + inProgressGeneration: inProgressGeneration.present + ? inProgressGeneration.value + : this.inProgressGeneration, + lastCompleteSyncAt: lastCompleteSyncAt.present + ? lastCompleteSyncAt.value + : this.lastCompleteSyncAt, + lastFailureCode: lastFailureCode.present + ? lastFailureCode.value + : this.lastFailureCode, + stateSchemaVersion: stateSchemaVersion ?? this.stateSchemaVersion, + stateJson: stateJson.present ? stateJson.value : this.stateJson, ); - ScheduleItemOverride copyWithCompanion(ScheduleItemOverridesCompanion data) { - return ScheduleItemOverride( + SyncCursor copyWithCompanion(SyncCursorsCompanion data) { + return SyncCursor( id: data.id.present ? data.id.value : this.id, accountId: data.accountId.present ? data.accountId.value : this.accountId, - sourceType: data.sourceType.present - ? data.sourceType.value - : this.sourceType, - sourceId: data.sourceId.present ? data.sourceId.value : this.sourceId, - overrideJson: data.overrideJson.present - ? data.overrideJson.value - : this.overrideJson, - createdAtLocal: data.createdAtLocal.present - ? data.createdAtLocal.value - : this.createdAtLocal, - updatedAtLocal: data.updatedAtLocal.present - ? data.updatedAtLocal.value - : this.updatedAtLocal, + projectionSourceId: data.projectionSourceId.present + ? data.projectionSourceId.value + : this.projectionSourceId, + provider: data.provider.present ? data.provider.value : this.provider, + transport: data.transport.present ? data.transport.value : this.transport, + syncScopeKind: data.syncScopeKind.present + ? data.syncScopeKind.value + : this.syncScopeKind, + davCollectionId: data.davCollectionId.present + ? data.davCollectionId.value + : this.davCollectionId, + cursorKind: data.cursorKind.present + ? data.cursorKind.value + : this.cursorKind, + cursorValue: data.cursorValue.present + ? data.cursorValue.value + : this.cursorValue, + rangeStart: data.rangeStart.present + ? data.rangeStart.value + : this.rangeStart, + rangeEnd: data.rangeEnd.present ? data.rangeEnd.value : this.rangeEnd, + baselineGeneration: data.baselineGeneration.present + ? data.baselineGeneration.value + : this.baselineGeneration, + inProgressCursor: data.inProgressCursor.present + ? data.inProgressCursor.value + : this.inProgressCursor, + inProgressGeneration: data.inProgressGeneration.present + ? data.inProgressGeneration.value + : this.inProgressGeneration, + lastCompleteSyncAt: data.lastCompleteSyncAt.present + ? data.lastCompleteSyncAt.value + : this.lastCompleteSyncAt, + lastFailureCode: data.lastFailureCode.present + ? data.lastFailureCode.value + : this.lastFailureCode, + stateSchemaVersion: data.stateSchemaVersion.present + ? data.stateSchemaVersion.value + : this.stateSchemaVersion, + stateJson: data.stateJson.present ? data.stateJson.value : this.stateJson, ); } @override String toString() { - return (StringBuffer('ScheduleItemOverride(') + return (StringBuffer('SyncCursor(') ..write('id: $id, ') ..write('accountId: $accountId, ') - ..write('sourceType: $sourceType, ') - ..write('sourceId: $sourceId, ') - ..write('overrideJson: $overrideJson, ') - ..write('createdAtLocal: $createdAtLocal, ') - ..write('updatedAtLocal: $updatedAtLocal') + ..write('projectionSourceId: $projectionSourceId, ') + ..write('provider: $provider, ') + ..write('transport: $transport, ') + ..write('syncScopeKind: $syncScopeKind, ') + ..write('davCollectionId: $davCollectionId, ') + ..write('cursorKind: $cursorKind, ') + ..write('cursorValue: $cursorValue, ') + ..write('rangeStart: $rangeStart, ') + ..write('rangeEnd: $rangeEnd, ') + ..write('baselineGeneration: $baselineGeneration, ') + ..write('inProgressCursor: $inProgressCursor, ') + ..write('inProgressGeneration: $inProgressGeneration, ') + ..write('lastCompleteSyncAt: $lastCompleteSyncAt, ') + ..write('lastFailureCode: $lastFailureCode, ') + ..write('stateSchemaVersion: $stateSchemaVersion, ') + ..write('stateJson: $stateJson') ..write(')')) .toString(); } @@ -21752,101 +22880,203 @@ class ScheduleItemOverride extends DataClass int get hashCode => Object.hash( id, accountId, - sourceType, - sourceId, - overrideJson, - createdAtLocal, - updatedAtLocal, + projectionSourceId, + provider, + transport, + syncScopeKind, + davCollectionId, + cursorKind, + cursorValue, + rangeStart, + rangeEnd, + baselineGeneration, + inProgressCursor, + inProgressGeneration, + lastCompleteSyncAt, + lastFailureCode, + stateSchemaVersion, + stateJson, ); @override bool operator ==(Object other) => identical(this, other) || - (other is ScheduleItemOverride && + (other is SyncCursor && other.id == this.id && other.accountId == this.accountId && - other.sourceType == this.sourceType && - other.sourceId == this.sourceId && - other.overrideJson == this.overrideJson && - other.createdAtLocal == this.createdAtLocal && - other.updatedAtLocal == this.updatedAtLocal); + other.projectionSourceId == this.projectionSourceId && + other.provider == this.provider && + other.transport == this.transport && + other.syncScopeKind == this.syncScopeKind && + other.davCollectionId == this.davCollectionId && + other.cursorKind == this.cursorKind && + other.cursorValue == this.cursorValue && + other.rangeStart == this.rangeStart && + other.rangeEnd == this.rangeEnd && + other.baselineGeneration == this.baselineGeneration && + other.inProgressCursor == this.inProgressCursor && + other.inProgressGeneration == this.inProgressGeneration && + other.lastCompleteSyncAt == this.lastCompleteSyncAt && + other.lastFailureCode == this.lastFailureCode && + other.stateSchemaVersion == this.stateSchemaVersion && + other.stateJson == this.stateJson); } -class ScheduleItemOverridesCompanion - extends UpdateCompanion { +class SyncCursorsCompanion extends UpdateCompanion { final Value id; final Value accountId; - final Value sourceType; - final Value sourceId; - final Value overrideJson; - final Value createdAtLocal; - final Value updatedAtLocal; + final Value projectionSourceId; + final Value provider; + final Value transport; + final Value syncScopeKind; + final Value davCollectionId; + final Value cursorKind; + final Value cursorValue; + final Value rangeStart; + final Value rangeEnd; + final Value baselineGeneration; + final Value inProgressCursor; + final Value inProgressGeneration; + final Value lastCompleteSyncAt; + final Value lastFailureCode; + final Value stateSchemaVersion; + final Value stateJson; final Value rowid; - const ScheduleItemOverridesCompanion({ + const SyncCursorsCompanion({ this.id = const Value.absent(), this.accountId = const Value.absent(), - this.sourceType = const Value.absent(), - this.sourceId = const Value.absent(), - this.overrideJson = const Value.absent(), - this.createdAtLocal = const Value.absent(), - this.updatedAtLocal = const Value.absent(), + this.projectionSourceId = const Value.absent(), + this.provider = const Value.absent(), + this.transport = const Value.absent(), + this.syncScopeKind = const Value.absent(), + this.davCollectionId = const Value.absent(), + this.cursorKind = const Value.absent(), + this.cursorValue = const Value.absent(), + this.rangeStart = const Value.absent(), + this.rangeEnd = const Value.absent(), + this.baselineGeneration = const Value.absent(), + this.inProgressCursor = const Value.absent(), + this.inProgressGeneration = const Value.absent(), + this.lastCompleteSyncAt = const Value.absent(), + this.lastFailureCode = const Value.absent(), + this.stateSchemaVersion = const Value.absent(), + this.stateJson = const Value.absent(), this.rowid = const Value.absent(), }); - ScheduleItemOverridesCompanion.insert({ + SyncCursorsCompanion.insert({ required String id, required String accountId, - required String sourceType, - required String sourceId, - required String overrideJson, - required int createdAtLocal, - required int updatedAtLocal, + this.projectionSourceId = const Value.absent(), + required String provider, + required String transport, + required String syncScopeKind, + this.davCollectionId = const Value.absent(), + required String cursorKind, + required String cursorValue, + this.rangeStart = const Value.absent(), + this.rangeEnd = const Value.absent(), + this.baselineGeneration = const Value.absent(), + this.inProgressCursor = const Value.absent(), + this.inProgressGeneration = const Value.absent(), + this.lastCompleteSyncAt = const Value.absent(), + this.lastFailureCode = const Value.absent(), + this.stateSchemaVersion = const Value.absent(), + this.stateJson = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), accountId = Value(accountId), - sourceType = Value(sourceType), - sourceId = Value(sourceId), - overrideJson = Value(overrideJson), - createdAtLocal = Value(createdAtLocal), - updatedAtLocal = Value(updatedAtLocal); - static Insertable custom({ + provider = Value(provider), + transport = Value(transport), + syncScopeKind = Value(syncScopeKind), + cursorKind = Value(cursorKind), + cursorValue = Value(cursorValue); + static Insertable custom({ Expression? id, Expression? accountId, - Expression? sourceType, - Expression? sourceId, - Expression? overrideJson, - Expression? createdAtLocal, - Expression? updatedAtLocal, + Expression? projectionSourceId, + Expression? provider, + Expression? transport, + Expression? syncScopeKind, + Expression? davCollectionId, + Expression? cursorKind, + Expression? cursorValue, + Expression? rangeStart, + Expression? rangeEnd, + Expression? baselineGeneration, + Expression? inProgressCursor, + Expression? inProgressGeneration, + Expression? lastCompleteSyncAt, + Expression? lastFailureCode, + Expression? stateSchemaVersion, + Expression? stateJson, Expression? rowid, }) { return RawValuesInsertable({ if (id != null) 'id': id, if (accountId != null) 'account_id': accountId, - if (sourceType != null) 'source_type': sourceType, - if (sourceId != null) 'source_id': sourceId, - if (overrideJson != null) 'override_json': overrideJson, - if (createdAtLocal != null) 'created_at_local': createdAtLocal, - if (updatedAtLocal != null) 'updated_at_local': updatedAtLocal, + if (projectionSourceId != null) + 'projection_source_id': projectionSourceId, + if (provider != null) 'provider': provider, + if (transport != null) 'transport': transport, + if (syncScopeKind != null) 'sync_scope_kind': syncScopeKind, + if (davCollectionId != null) 'dav_collection_id': davCollectionId, + if (cursorKind != null) 'cursor_kind': cursorKind, + if (cursorValue != null) 'cursor_value': cursorValue, + if (rangeStart != null) 'range_start': rangeStart, + if (rangeEnd != null) 'range_end': rangeEnd, + if (baselineGeneration != null) 'baseline_generation': baselineGeneration, + if (inProgressCursor != null) 'in_progress_cursor': inProgressCursor, + if (inProgressGeneration != null) + 'in_progress_generation': inProgressGeneration, + if (lastCompleteSyncAt != null) + 'last_complete_sync_at': lastCompleteSyncAt, + if (lastFailureCode != null) 'last_failure_code': lastFailureCode, + if (stateSchemaVersion != null) + 'state_schema_version': stateSchemaVersion, + if (stateJson != null) 'state_json': stateJson, if (rowid != null) 'rowid': rowid, }); } - ScheduleItemOverridesCompanion copyWith({ + SyncCursorsCompanion copyWith({ Value? id, Value? accountId, - Value? sourceType, - Value? sourceId, - Value? overrideJson, - Value? createdAtLocal, - Value? updatedAtLocal, + Value? projectionSourceId, + Value? provider, + Value? transport, + Value? syncScopeKind, + Value? davCollectionId, + Value? cursorKind, + Value? cursorValue, + Value? rangeStart, + Value? rangeEnd, + Value? baselineGeneration, + Value? inProgressCursor, + Value? inProgressGeneration, + Value? lastCompleteSyncAt, + Value? lastFailureCode, + Value? stateSchemaVersion, + Value? stateJson, Value? rowid, }) { - return ScheduleItemOverridesCompanion( + return SyncCursorsCompanion( id: id ?? this.id, accountId: accountId ?? this.accountId, - sourceType: sourceType ?? this.sourceType, - sourceId: sourceId ?? this.sourceId, - overrideJson: overrideJson ?? this.overrideJson, - createdAtLocal: createdAtLocal ?? this.createdAtLocal, - updatedAtLocal: updatedAtLocal ?? this.updatedAtLocal, + projectionSourceId: projectionSourceId ?? this.projectionSourceId, + provider: provider ?? this.provider, + transport: transport ?? this.transport, + syncScopeKind: syncScopeKind ?? this.syncScopeKind, + davCollectionId: davCollectionId ?? this.davCollectionId, + cursorKind: cursorKind ?? this.cursorKind, + cursorValue: cursorValue ?? this.cursorValue, + rangeStart: rangeStart ?? this.rangeStart, + rangeEnd: rangeEnd ?? this.rangeEnd, + baselineGeneration: baselineGeneration ?? this.baselineGeneration, + inProgressCursor: inProgressCursor ?? this.inProgressCursor, + inProgressGeneration: inProgressGeneration ?? this.inProgressGeneration, + lastCompleteSyncAt: lastCompleteSyncAt ?? this.lastCompleteSyncAt, + lastFailureCode: lastFailureCode ?? this.lastFailureCode, + stateSchemaVersion: stateSchemaVersion ?? this.stateSchemaVersion, + stateJson: stateJson ?? this.stateJson, rowid: rowid ?? this.rowid, ); } @@ -21860,20 +23090,53 @@ class ScheduleItemOverridesCompanion if (accountId.present) { map['account_id'] = Variable(accountId.value); } - if (sourceType.present) { - map['source_type'] = Variable(sourceType.value); + if (projectionSourceId.present) { + map['projection_source_id'] = Variable(projectionSourceId.value); } - if (sourceId.present) { - map['source_id'] = Variable(sourceId.value); + if (provider.present) { + map['provider'] = Variable(provider.value); } - if (overrideJson.present) { - map['override_json'] = Variable(overrideJson.value); + if (transport.present) { + map['transport'] = Variable(transport.value); } - if (createdAtLocal.present) { - map['created_at_local'] = Variable(createdAtLocal.value); + if (syncScopeKind.present) { + map['sync_scope_kind'] = Variable(syncScopeKind.value); } - if (updatedAtLocal.present) { - map['updated_at_local'] = Variable(updatedAtLocal.value); + if (davCollectionId.present) { + map['dav_collection_id'] = Variable(davCollectionId.value); + } + if (cursorKind.present) { + map['cursor_kind'] = Variable(cursorKind.value); + } + if (cursorValue.present) { + map['cursor_value'] = Variable(cursorValue.value); + } + if (rangeStart.present) { + map['range_start'] = Variable(rangeStart.value); + } + if (rangeEnd.present) { + map['range_end'] = Variable(rangeEnd.value); + } + if (baselineGeneration.present) { + map['baseline_generation'] = Variable(baselineGeneration.value); + } + if (inProgressCursor.present) { + map['in_progress_cursor'] = Variable(inProgressCursor.value); + } + if (inProgressGeneration.present) { + map['in_progress_generation'] = Variable(inProgressGeneration.value); + } + if (lastCompleteSyncAt.present) { + map['last_complete_sync_at'] = Variable(lastCompleteSyncAt.value); + } + if (lastFailureCode.present) { + map['last_failure_code'] = Variable(lastFailureCode.value); + } + if (stateSchemaVersion.present) { + map['state_schema_version'] = Variable(stateSchemaVersion.value); + } + if (stateJson.present) { + map['state_json'] = Variable(stateJson.value); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -21883,136 +23146,523 @@ class ScheduleItemOverridesCompanion @override String toString() { - return (StringBuffer('ScheduleItemOverridesCompanion(') + return (StringBuffer('SyncCursorsCompanion(') ..write('id: $id, ') ..write('accountId: $accountId, ') - ..write('sourceType: $sourceType, ') - ..write('sourceId: $sourceId, ') - ..write('overrideJson: $overrideJson, ') - ..write('createdAtLocal: $createdAtLocal, ') - ..write('updatedAtLocal: $updatedAtLocal, ') + ..write('projectionSourceId: $projectionSourceId, ') + ..write('provider: $provider, ') + ..write('transport: $transport, ') + ..write('syncScopeKind: $syncScopeKind, ') + ..write('davCollectionId: $davCollectionId, ') + ..write('cursorKind: $cursorKind, ') + ..write('cursorValue: $cursorValue, ') + ..write('rangeStart: $rangeStart, ') + ..write('rangeEnd: $rangeEnd, ') + ..write('baselineGeneration: $baselineGeneration, ') + ..write('inProgressCursor: $inProgressCursor, ') + ..write('inProgressGeneration: $inProgressGeneration, ') + ..write('lastCompleteSyncAt: $lastCompleteSyncAt, ') + ..write('lastFailureCode: $lastFailureCode, ') + ..write('stateSchemaVersion: $stateSchemaVersion, ') + ..write('stateJson: $stateJson, ') ..write('rowid: $rowid') ..write(')')) .toString(); } } -class $NotificationScheduleTable extends NotificationSchedule - with TableInfo<$NotificationScheduleTable, NotificationScheduleData> { +class $CalendarColorsTable extends CalendarColors + with TableInfo<$CalendarColorsTable, CalendarColor> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $NotificationScheduleTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _accountIdMeta = const VerificationMeta( - 'accountId', + $CalendarColorsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _providerMeta = const VerificationMeta( + 'provider', ); @override - late final GeneratedColumn accountId = GeneratedColumn( - 'account_id', + late final GeneratedColumn provider = GeneratedColumn( + 'provider', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES accounts (id) ON DELETE CASCADE', - ), ); - static const VerificationMeta _sourceTypeMeta = const VerificationMeta( - 'sourceType', + static const VerificationMeta _colorTypeMeta = const VerificationMeta( + 'colorType', ); @override - late final GeneratedColumn sourceType = GeneratedColumn( - 'source_type', + late final GeneratedColumn colorType = GeneratedColumn( + 'color_type', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _sourceIdMeta = const VerificationMeta( - 'sourceId', + static const VerificationMeta _colorIdMeta = const VerificationMeta( + 'colorId', ); @override - late final GeneratedColumn sourceId = GeneratedColumn( - 'source_id', + late final GeneratedColumn colorId = GeneratedColumn( + 'color_id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _scheduledAtUtcMeta = const VerificationMeta( - 'scheduledAtUtc', - ); - @override - late final GeneratedColumn scheduledAtUtc = GeneratedColumn( - 'scheduled_at_utc', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: true, + static const VerificationMeta _backgroundMeta = const VerificationMeta( + 'background', ); - static const VerificationMeta _titleMeta = const VerificationMeta('title'); @override - late final GeneratedColumn title = GeneratedColumn( - 'title', + late final GeneratedColumn background = GeneratedColumn( + 'background', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _bodyMeta = const VerificationMeta('body'); + static const VerificationMeta _foregroundMeta = const VerificationMeta( + 'foreground', + ); @override - late final GeneratedColumn body = GeneratedColumn( - 'body', + late final GeneratedColumn foreground = GeneratedColumn( + 'foreground', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _sentAtUtcMeta = const VerificationMeta( - 'sentAtUtc', + static const VerificationMeta _rawJsonMeta = const VerificationMeta( + 'rawJson', ); @override - late final GeneratedColumn sentAtUtc = GeneratedColumn( - 'sent_at_utc', + late final GeneratedColumn rawJson = GeneratedColumn( + 'raw_json', aliasedName, true, - type: DriftSqlType.int, + type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _dismissedAtUtcMeta = const VerificationMeta( - 'dismissedAtUtc', - ); @override - late final GeneratedColumn dismissedAtUtc = GeneratedColumn( - 'dismissed_at_utc', - aliasedName, - true, - type: DriftSqlType.int, - requiredDuringInsert: false, - ); - static const VerificationMeta _snoozedUntilUtcMeta = const VerificationMeta( - 'snoozedUntilUtc', - ); + List get $columns => [ + provider, + colorType, + colorId, + background, + foreground, + rawJson, + ]; @override - late final GeneratedColumn snoozedUntilUtc = GeneratedColumn( - 'snoozed_until_utc', - aliasedName, - true, - type: DriftSqlType.int, - requiredDuringInsert: false, - ); - static const VerificationMeta _createdAtLocalMeta = const VerificationMeta( - 'createdAtLocal', - ); + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'calendar_colors'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('provider')) { + context.handle( + _providerMeta, + provider.isAcceptableOrUnknown(data['provider']!, _providerMeta), + ); + } else if (isInserting) { + context.missing(_providerMeta); + } + if (data.containsKey('color_type')) { + context.handle( + _colorTypeMeta, + colorType.isAcceptableOrUnknown(data['color_type']!, _colorTypeMeta), + ); + } else if (isInserting) { + context.missing(_colorTypeMeta); + } + if (data.containsKey('color_id')) { + context.handle( + _colorIdMeta, + colorId.isAcceptableOrUnknown(data['color_id']!, _colorIdMeta), + ); + } else if (isInserting) { + context.missing(_colorIdMeta); + } + if (data.containsKey('background')) { + context.handle( + _backgroundMeta, + background.isAcceptableOrUnknown(data['background']!, _backgroundMeta), + ); + } else if (isInserting) { + context.missing(_backgroundMeta); + } + if (data.containsKey('foreground')) { + context.handle( + _foregroundMeta, + foreground.isAcceptableOrUnknown(data['foreground']!, _foregroundMeta), + ); + } + if (data.containsKey('raw_json')) { + context.handle( + _rawJsonMeta, + rawJson.isAcceptableOrUnknown(data['raw_json']!, _rawJsonMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {provider, colorType, colorId}; + @override + CalendarColor map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CalendarColor( + provider: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}provider'], + )!, + colorType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}color_type'], + )!, + colorId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}color_id'], + )!, + background: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}background'], + )!, + foreground: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}foreground'], + ), + rawJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}raw_json'], + ), + ); + } + + @override + $CalendarColorsTable createAlias(String alias) { + return $CalendarColorsTable(attachedDatabase, alias); + } +} + +class CalendarColor extends DataClass implements Insertable { + final String provider; + final String colorType; + final String colorId; + final String background; + final String? foreground; + final String? rawJson; + const CalendarColor({ + required this.provider, + required this.colorType, + required this.colorId, + required this.background, + this.foreground, + this.rawJson, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['provider'] = Variable(provider); + map['color_type'] = Variable(colorType); + map['color_id'] = Variable(colorId); + map['background'] = Variable(background); + if (!nullToAbsent || foreground != null) { + map['foreground'] = Variable(foreground); + } + if (!nullToAbsent || rawJson != null) { + map['raw_json'] = Variable(rawJson); + } + return map; + } + + CalendarColorsCompanion toCompanion(bool nullToAbsent) { + return CalendarColorsCompanion( + provider: Value(provider), + colorType: Value(colorType), + colorId: Value(colorId), + background: Value(background), + foreground: foreground == null && nullToAbsent + ? const Value.absent() + : Value(foreground), + rawJson: rawJson == null && nullToAbsent + ? const Value.absent() + : Value(rawJson), + ); + } + + factory CalendarColor.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CalendarColor( + provider: serializer.fromJson(json['provider']), + colorType: serializer.fromJson(json['colorType']), + colorId: serializer.fromJson(json['colorId']), + background: serializer.fromJson(json['background']), + foreground: serializer.fromJson(json['foreground']), + rawJson: serializer.fromJson(json['rawJson']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'provider': serializer.toJson(provider), + 'colorType': serializer.toJson(colorType), + 'colorId': serializer.toJson(colorId), + 'background': serializer.toJson(background), + 'foreground': serializer.toJson(foreground), + 'rawJson': serializer.toJson(rawJson), + }; + } + + CalendarColor copyWith({ + String? provider, + String? colorType, + String? colorId, + String? background, + Value foreground = const Value.absent(), + Value rawJson = const Value.absent(), + }) => CalendarColor( + provider: provider ?? this.provider, + colorType: colorType ?? this.colorType, + colorId: colorId ?? this.colorId, + background: background ?? this.background, + foreground: foreground.present ? foreground.value : this.foreground, + rawJson: rawJson.present ? rawJson.value : this.rawJson, + ); + CalendarColor copyWithCompanion(CalendarColorsCompanion data) { + return CalendarColor( + provider: data.provider.present ? data.provider.value : this.provider, + colorType: data.colorType.present ? data.colorType.value : this.colorType, + colorId: data.colorId.present ? data.colorId.value : this.colorId, + background: data.background.present + ? data.background.value + : this.background, + foreground: data.foreground.present + ? data.foreground.value + : this.foreground, + rawJson: data.rawJson.present ? data.rawJson.value : this.rawJson, + ); + } + + @override + String toString() { + return (StringBuffer('CalendarColor(') + ..write('provider: $provider, ') + ..write('colorType: $colorType, ') + ..write('colorId: $colorId, ') + ..write('background: $background, ') + ..write('foreground: $foreground, ') + ..write('rawJson: $rawJson') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + provider, + colorType, + colorId, + background, + foreground, + rawJson, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CalendarColor && + other.provider == this.provider && + other.colorType == this.colorType && + other.colorId == this.colorId && + other.background == this.background && + other.foreground == this.foreground && + other.rawJson == this.rawJson); +} + +class CalendarColorsCompanion extends UpdateCompanion { + final Value provider; + final Value colorType; + final Value colorId; + final Value background; + final Value foreground; + final Value rawJson; + final Value rowid; + const CalendarColorsCompanion({ + this.provider = const Value.absent(), + this.colorType = const Value.absent(), + this.colorId = const Value.absent(), + this.background = const Value.absent(), + this.foreground = const Value.absent(), + this.rawJson = const Value.absent(), + this.rowid = const Value.absent(), + }); + CalendarColorsCompanion.insert({ + required String provider, + required String colorType, + required String colorId, + required String background, + this.foreground = const Value.absent(), + this.rawJson = const Value.absent(), + this.rowid = const Value.absent(), + }) : provider = Value(provider), + colorType = Value(colorType), + colorId = Value(colorId), + background = Value(background); + static Insertable custom({ + Expression? provider, + Expression? colorType, + Expression? colorId, + Expression? background, + Expression? foreground, + Expression? rawJson, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (provider != null) 'provider': provider, + if (colorType != null) 'color_type': colorType, + if (colorId != null) 'color_id': colorId, + if (background != null) 'background': background, + if (foreground != null) 'foreground': foreground, + if (rawJson != null) 'raw_json': rawJson, + if (rowid != null) 'rowid': rowid, + }); + } + + CalendarColorsCompanion copyWith({ + Value? provider, + Value? colorType, + Value? colorId, + Value? background, + Value? foreground, + Value? rawJson, + Value? rowid, + }) { + return CalendarColorsCompanion( + provider: provider ?? this.provider, + colorType: colorType ?? this.colorType, + colorId: colorId ?? this.colorId, + background: background ?? this.background, + foreground: foreground ?? this.foreground, + rawJson: rawJson ?? this.rawJson, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (provider.present) { + map['provider'] = Variable(provider.value); + } + if (colorType.present) { + map['color_type'] = Variable(colorType.value); + } + if (colorId.present) { + map['color_id'] = Variable(colorId.value); + } + if (background.present) { + map['background'] = Variable(background.value); + } + if (foreground.present) { + map['foreground'] = Variable(foreground.value); + } + if (rawJson.present) { + map['raw_json'] = Variable(rawJson.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CalendarColorsCompanion(') + ..write('provider: $provider, ') + ..write('colorType: $colorType, ') + ..write('colorId: $colorId, ') + ..write('background: $background, ') + ..write('foreground: $foreground, ') + ..write('rawJson: $rawJson, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $ScheduleItemOverridesTable extends ScheduleItemOverrides + with TableInfo<$ScheduleItemOverridesTable, ScheduleItemOverride> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ScheduleItemOverridesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _accountIdMeta = const VerificationMeta( + 'accountId', + ); + @override + late final GeneratedColumn accountId = GeneratedColumn( + 'account_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES accounts (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _sourceTypeMeta = const VerificationMeta( + 'sourceType', + ); + @override + late final GeneratedColumn sourceType = GeneratedColumn( + 'source_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _sourceIdMeta = const VerificationMeta( + 'sourceId', + ); + @override + late final GeneratedColumn sourceId = GeneratedColumn( + 'source_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _overrideJsonMeta = const VerificationMeta( + 'overrideJson', + ); + @override + late final GeneratedColumn overrideJson = GeneratedColumn( + 'override_json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtLocalMeta = const VerificationMeta( + 'createdAtLocal', + ); @override late final GeneratedColumn createdAtLocal = GeneratedColumn( 'created_at_local', @@ -22038,12 +23688,7 @@ class $NotificationScheduleTable extends NotificationSchedule accountId, sourceType, sourceId, - scheduledAtUtc, - title, - body, - sentAtUtc, - dismissedAtUtc, - snoozedUntilUtc, + overrideJson, createdAtLocal, updatedAtLocal, ]; @@ -22051,10 +23696,10 @@ class $NotificationScheduleTable extends NotificationSchedule String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'notification_schedule'; + static const String $name = 'schedule_item_overrides'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); @@ -22088,54 +23733,16 @@ class $NotificationScheduleTable extends NotificationSchedule } else if (isInserting) { context.missing(_sourceIdMeta); } - if (data.containsKey('scheduled_at_utc')) { + if (data.containsKey('override_json')) { context.handle( - _scheduledAtUtcMeta, - scheduledAtUtc.isAcceptableOrUnknown( - data['scheduled_at_utc']!, - _scheduledAtUtcMeta, + _overrideJsonMeta, + overrideJson.isAcceptableOrUnknown( + data['override_json']!, + _overrideJsonMeta, ), ); } else if (isInserting) { - context.missing(_scheduledAtUtcMeta); - } - if (data.containsKey('title')) { - context.handle( - _titleMeta, - title.isAcceptableOrUnknown(data['title']!, _titleMeta), - ); - } else if (isInserting) { - context.missing(_titleMeta); - } - if (data.containsKey('body')) { - context.handle( - _bodyMeta, - body.isAcceptableOrUnknown(data['body']!, _bodyMeta), - ); - } - if (data.containsKey('sent_at_utc')) { - context.handle( - _sentAtUtcMeta, - sentAtUtc.isAcceptableOrUnknown(data['sent_at_utc']!, _sentAtUtcMeta), - ); - } - if (data.containsKey('dismissed_at_utc')) { - context.handle( - _dismissedAtUtcMeta, - dismissedAtUtc.isAcceptableOrUnknown( - data['dismissed_at_utc']!, - _dismissedAtUtcMeta, - ), - ); - } - if (data.containsKey('snoozed_until_utc')) { - context.handle( - _snoozedUntilUtcMeta, - snoozedUntilUtc.isAcceptableOrUnknown( - data['snoozed_until_utc']!, - _snoozedUntilUtcMeta, - ), - ); + context.missing(_overrideJsonMeta); } if (data.containsKey('created_at_local')) { context.handle( @@ -22165,12 +23772,9 @@ class $NotificationScheduleTable extends NotificationSchedule @override Set get $primaryKey => {id}; @override - NotificationScheduleData map( - Map data, { - String? tablePrefix, - }) { + ScheduleItemOverride map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return NotificationScheduleData( + return ScheduleItemOverride( id: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}id'], @@ -22187,30 +23791,10 @@ class $NotificationScheduleTable extends NotificationSchedule DriftSqlType.string, data['${effectivePrefix}source_id'], )!, - scheduledAtUtc: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}scheduled_at_utc'], - )!, - title: attachedDatabase.typeMapping.read( + overrideJson: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}title'], + data['${effectivePrefix}override_json'], )!, - body: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}body'], - ), - sentAtUtc: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}sent_at_utc'], - ), - dismissedAtUtc: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}dismissed_at_utc'], - ), - snoozedUntilUtc: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}snoozed_until_utc'], - ), createdAtLocal: attachedDatabase.typeMapping.read( DriftSqlType.int, data['${effectivePrefix}created_at_local'], @@ -22223,36 +23807,26 @@ class $NotificationScheduleTable extends NotificationSchedule } @override - $NotificationScheduleTable createAlias(String alias) { - return $NotificationScheduleTable(attachedDatabase, alias); + $ScheduleItemOverridesTable createAlias(String alias) { + return $ScheduleItemOverridesTable(attachedDatabase, alias); } } -class NotificationScheduleData extends DataClass - implements Insertable { +class ScheduleItemOverride extends DataClass + implements Insertable { final String id; final String accountId; final String sourceType; final String sourceId; - final int scheduledAtUtc; - final String title; - final String? body; - final int? sentAtUtc; - final int? dismissedAtUtc; - final int? snoozedUntilUtc; + final String overrideJson; final int createdAtLocal; final int updatedAtLocal; - const NotificationScheduleData({ + const ScheduleItemOverride({ required this.id, required this.accountId, required this.sourceType, required this.sourceId, - required this.scheduledAtUtc, - required this.title, - this.body, - this.sentAtUtc, - this.dismissedAtUtc, - this.snoozedUntilUtc, + required this.overrideJson, required this.createdAtLocal, required this.updatedAtLocal, }); @@ -22263,64 +23837,35 @@ class NotificationScheduleData extends DataClass map['account_id'] = Variable(accountId); map['source_type'] = Variable(sourceType); map['source_id'] = Variable(sourceId); - map['scheduled_at_utc'] = Variable(scheduledAtUtc); - map['title'] = Variable(title); - if (!nullToAbsent || body != null) { - map['body'] = Variable(body); - } - if (!nullToAbsent || sentAtUtc != null) { - map['sent_at_utc'] = Variable(sentAtUtc); - } - if (!nullToAbsent || dismissedAtUtc != null) { - map['dismissed_at_utc'] = Variable(dismissedAtUtc); - } - if (!nullToAbsent || snoozedUntilUtc != null) { - map['snoozed_until_utc'] = Variable(snoozedUntilUtc); - } + map['override_json'] = Variable(overrideJson); map['created_at_local'] = Variable(createdAtLocal); map['updated_at_local'] = Variable(updatedAtLocal); return map; } - NotificationScheduleCompanion toCompanion(bool nullToAbsent) { - return NotificationScheduleCompanion( + ScheduleItemOverridesCompanion toCompanion(bool nullToAbsent) { + return ScheduleItemOverridesCompanion( id: Value(id), accountId: Value(accountId), sourceType: Value(sourceType), sourceId: Value(sourceId), - scheduledAtUtc: Value(scheduledAtUtc), - title: Value(title), - body: body == null && nullToAbsent ? const Value.absent() : Value(body), - sentAtUtc: sentAtUtc == null && nullToAbsent - ? const Value.absent() - : Value(sentAtUtc), - dismissedAtUtc: dismissedAtUtc == null && nullToAbsent - ? const Value.absent() - : Value(dismissedAtUtc), - snoozedUntilUtc: snoozedUntilUtc == null && nullToAbsent - ? const Value.absent() - : Value(snoozedUntilUtc), + overrideJson: Value(overrideJson), createdAtLocal: Value(createdAtLocal), updatedAtLocal: Value(updatedAtLocal), ); } - factory NotificationScheduleData.fromJson( + factory ScheduleItemOverride.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return NotificationScheduleData( + return ScheduleItemOverride( id: serializer.fromJson(json['id']), accountId: serializer.fromJson(json['accountId']), sourceType: serializer.fromJson(json['sourceType']), sourceId: serializer.fromJson(json['sourceId']), - scheduledAtUtc: serializer.fromJson(json['scheduledAtUtc']), - title: serializer.fromJson(json['title']), - body: serializer.fromJson(json['body']), - sentAtUtc: serializer.fromJson(json['sentAtUtc']), - dismissedAtUtc: serializer.fromJson(json['dismissedAtUtc']), - snoozedUntilUtc: serializer.fromJson(json['snoozedUntilUtc']), + overrideJson: serializer.fromJson(json['overrideJson']), createdAtLocal: serializer.fromJson(json['createdAtLocal']), updatedAtLocal: serializer.fromJson(json['updatedAtLocal']), ); @@ -22333,70 +23878,40 @@ class NotificationScheduleData extends DataClass 'accountId': serializer.toJson(accountId), 'sourceType': serializer.toJson(sourceType), 'sourceId': serializer.toJson(sourceId), - 'scheduledAtUtc': serializer.toJson(scheduledAtUtc), - 'title': serializer.toJson(title), - 'body': serializer.toJson(body), - 'sentAtUtc': serializer.toJson(sentAtUtc), - 'dismissedAtUtc': serializer.toJson(dismissedAtUtc), - 'snoozedUntilUtc': serializer.toJson(snoozedUntilUtc), + 'overrideJson': serializer.toJson(overrideJson), 'createdAtLocal': serializer.toJson(createdAtLocal), 'updatedAtLocal': serializer.toJson(updatedAtLocal), }; } - NotificationScheduleData copyWith({ + ScheduleItemOverride copyWith({ String? id, String? accountId, String? sourceType, String? sourceId, - int? scheduledAtUtc, - String? title, - Value body = const Value.absent(), - Value sentAtUtc = const Value.absent(), - Value dismissedAtUtc = const Value.absent(), - Value snoozedUntilUtc = const Value.absent(), + String? overrideJson, int? createdAtLocal, int? updatedAtLocal, - }) => NotificationScheduleData( + }) => ScheduleItemOverride( id: id ?? this.id, accountId: accountId ?? this.accountId, sourceType: sourceType ?? this.sourceType, sourceId: sourceId ?? this.sourceId, - scheduledAtUtc: scheduledAtUtc ?? this.scheduledAtUtc, - title: title ?? this.title, - body: body.present ? body.value : this.body, - sentAtUtc: sentAtUtc.present ? sentAtUtc.value : this.sentAtUtc, - dismissedAtUtc: dismissedAtUtc.present - ? dismissedAtUtc.value - : this.dismissedAtUtc, - snoozedUntilUtc: snoozedUntilUtc.present - ? snoozedUntilUtc.value - : this.snoozedUntilUtc, + overrideJson: overrideJson ?? this.overrideJson, createdAtLocal: createdAtLocal ?? this.createdAtLocal, updatedAtLocal: updatedAtLocal ?? this.updatedAtLocal, ); - NotificationScheduleData copyWithCompanion( - NotificationScheduleCompanion data, - ) { - return NotificationScheduleData( + ScheduleItemOverride copyWithCompanion(ScheduleItemOverridesCompanion data) { + return ScheduleItemOverride( id: data.id.present ? data.id.value : this.id, accountId: data.accountId.present ? data.accountId.value : this.accountId, sourceType: data.sourceType.present ? data.sourceType.value : this.sourceType, sourceId: data.sourceId.present ? data.sourceId.value : this.sourceId, - scheduledAtUtc: data.scheduledAtUtc.present - ? data.scheduledAtUtc.value - : this.scheduledAtUtc, - title: data.title.present ? data.title.value : this.title, - body: data.body.present ? data.body.value : this.body, - sentAtUtc: data.sentAtUtc.present ? data.sentAtUtc.value : this.sentAtUtc, - dismissedAtUtc: data.dismissedAtUtc.present - ? data.dismissedAtUtc.value - : this.dismissedAtUtc, - snoozedUntilUtc: data.snoozedUntilUtc.present - ? data.snoozedUntilUtc.value - : this.snoozedUntilUtc, + overrideJson: data.overrideJson.present + ? data.overrideJson.value + : this.overrideJson, createdAtLocal: data.createdAtLocal.present ? data.createdAtLocal.value : this.createdAtLocal, @@ -22408,17 +23923,12 @@ class NotificationScheduleData extends DataClass @override String toString() { - return (StringBuffer('NotificationScheduleData(') + return (StringBuffer('ScheduleItemOverride(') ..write('id: $id, ') ..write('accountId: $accountId, ') ..write('sourceType: $sourceType, ') ..write('sourceId: $sourceId, ') - ..write('scheduledAtUtc: $scheduledAtUtc, ') - ..write('title: $title, ') - ..write('body: $body, ') - ..write('sentAtUtc: $sentAtUtc, ') - ..write('dismissedAtUtc: $dismissedAtUtc, ') - ..write('snoozedUntilUtc: $snoozedUntilUtc, ') + ..write('overrideJson: $overrideJson, ') ..write('createdAtLocal: $createdAtLocal, ') ..write('updatedAtLocal: $updatedAtLocal') ..write(')')) @@ -22431,74 +23941,49 @@ class NotificationScheduleData extends DataClass accountId, sourceType, sourceId, - scheduledAtUtc, - title, - body, - sentAtUtc, - dismissedAtUtc, - snoozedUntilUtc, + overrideJson, createdAtLocal, updatedAtLocal, ); @override bool operator ==(Object other) => identical(this, other) || - (other is NotificationScheduleData && + (other is ScheduleItemOverride && other.id == this.id && other.accountId == this.accountId && other.sourceType == this.sourceType && other.sourceId == this.sourceId && - other.scheduledAtUtc == this.scheduledAtUtc && - other.title == this.title && - other.body == this.body && - other.sentAtUtc == this.sentAtUtc && - other.dismissedAtUtc == this.dismissedAtUtc && - other.snoozedUntilUtc == this.snoozedUntilUtc && + other.overrideJson == this.overrideJson && other.createdAtLocal == this.createdAtLocal && other.updatedAtLocal == this.updatedAtLocal); } -class NotificationScheduleCompanion - extends UpdateCompanion { +class ScheduleItemOverridesCompanion + extends UpdateCompanion { final Value id; final Value accountId; final Value sourceType; final Value sourceId; - final Value scheduledAtUtc; - final Value title; - final Value body; - final Value sentAtUtc; - final Value dismissedAtUtc; - final Value snoozedUntilUtc; + final Value overrideJson; final Value createdAtLocal; final Value updatedAtLocal; final Value rowid; - const NotificationScheduleCompanion({ + const ScheduleItemOverridesCompanion({ this.id = const Value.absent(), this.accountId = const Value.absent(), this.sourceType = const Value.absent(), this.sourceId = const Value.absent(), - this.scheduledAtUtc = const Value.absent(), - this.title = const Value.absent(), - this.body = const Value.absent(), - this.sentAtUtc = const Value.absent(), - this.dismissedAtUtc = const Value.absent(), - this.snoozedUntilUtc = const Value.absent(), + this.overrideJson = const Value.absent(), this.createdAtLocal = const Value.absent(), this.updatedAtLocal = const Value.absent(), this.rowid = const Value.absent(), }); - NotificationScheduleCompanion.insert({ + ScheduleItemOverridesCompanion.insert({ required String id, required String accountId, required String sourceType, required String sourceId, - required int scheduledAtUtc, - required String title, - this.body = const Value.absent(), - this.sentAtUtc = const Value.absent(), - this.dismissedAtUtc = const Value.absent(), - this.snoozedUntilUtc = const Value.absent(), + required String overrideJson, required int createdAtLocal, required int updatedAtLocal, this.rowid = const Value.absent(), @@ -22506,21 +23991,15 @@ class NotificationScheduleCompanion accountId = Value(accountId), sourceType = Value(sourceType), sourceId = Value(sourceId), - scheduledAtUtc = Value(scheduledAtUtc), - title = Value(title), + overrideJson = Value(overrideJson), createdAtLocal = Value(createdAtLocal), updatedAtLocal = Value(updatedAtLocal); - static Insertable custom({ + static Insertable custom({ Expression? id, Expression? accountId, Expression? sourceType, Expression? sourceId, - Expression? scheduledAtUtc, - Expression? title, - Expression? body, - Expression? sentAtUtc, - Expression? dismissedAtUtc, - Expression? snoozedUntilUtc, + Expression? overrideJson, Expression? createdAtLocal, Expression? updatedAtLocal, Expression? rowid, @@ -22530,44 +24009,29 @@ class NotificationScheduleCompanion if (accountId != null) 'account_id': accountId, if (sourceType != null) 'source_type': sourceType, if (sourceId != null) 'source_id': sourceId, - if (scheduledAtUtc != null) 'scheduled_at_utc': scheduledAtUtc, - if (title != null) 'title': title, - if (body != null) 'body': body, - if (sentAtUtc != null) 'sent_at_utc': sentAtUtc, - if (dismissedAtUtc != null) 'dismissed_at_utc': dismissedAtUtc, - if (snoozedUntilUtc != null) 'snoozed_until_utc': snoozedUntilUtc, + if (overrideJson != null) 'override_json': overrideJson, if (createdAtLocal != null) 'created_at_local': createdAtLocal, if (updatedAtLocal != null) 'updated_at_local': updatedAtLocal, if (rowid != null) 'rowid': rowid, }); } - NotificationScheduleCompanion copyWith({ + ScheduleItemOverridesCompanion copyWith({ Value? id, Value? accountId, Value? sourceType, Value? sourceId, - Value? scheduledAtUtc, - Value? title, - Value? body, - Value? sentAtUtc, - Value? dismissedAtUtc, - Value? snoozedUntilUtc, + Value? overrideJson, Value? createdAtLocal, Value? updatedAtLocal, Value? rowid, }) { - return NotificationScheduleCompanion( + return ScheduleItemOverridesCompanion( id: id ?? this.id, accountId: accountId ?? this.accountId, sourceType: sourceType ?? this.sourceType, sourceId: sourceId ?? this.sourceId, - scheduledAtUtc: scheduledAtUtc ?? this.scheduledAtUtc, - title: title ?? this.title, - body: body ?? this.body, - sentAtUtc: sentAtUtc ?? this.sentAtUtc, - dismissedAtUtc: dismissedAtUtc ?? this.dismissedAtUtc, - snoozedUntilUtc: snoozedUntilUtc ?? this.snoozedUntilUtc, + overrideJson: overrideJson ?? this.overrideJson, createdAtLocal: createdAtLocal ?? this.createdAtLocal, updatedAtLocal: updatedAtLocal ?? this.updatedAtLocal, rowid: rowid ?? this.rowid, @@ -22589,23 +24053,8 @@ class NotificationScheduleCompanion if (sourceId.present) { map['source_id'] = Variable(sourceId.value); } - if (scheduledAtUtc.present) { - map['scheduled_at_utc'] = Variable(scheduledAtUtc.value); - } - if (title.present) { - map['title'] = Variable(title.value); - } - if (body.present) { - map['body'] = Variable(body.value); - } - if (sentAtUtc.present) { - map['sent_at_utc'] = Variable(sentAtUtc.value); - } - if (dismissedAtUtc.present) { - map['dismissed_at_utc'] = Variable(dismissedAtUtc.value); - } - if (snoozedUntilUtc.present) { - map['snoozed_until_utc'] = Variable(snoozedUntilUtc.value); + if (overrideJson.present) { + map['override_json'] = Variable(overrideJson.value); } if (createdAtLocal.present) { map['created_at_local'] = Variable(createdAtLocal.value); @@ -22621,17 +24070,12 @@ class NotificationScheduleCompanion @override String toString() { - return (StringBuffer('NotificationScheduleCompanion(') + return (StringBuffer('ScheduleItemOverridesCompanion(') ..write('id: $id, ') ..write('accountId: $accountId, ') ..write('sourceType: $sourceType, ') ..write('sourceId: $sourceId, ') - ..write('scheduledAtUtc: $scheduledAtUtc, ') - ..write('title: $title, ') - ..write('body: $body, ') - ..write('sentAtUtc: $sentAtUtc, ') - ..write('dismissedAtUtc: $dismissedAtUtc, ') - ..write('snoozedUntilUtc: $snoozedUntilUtc, ') + ..write('overrideJson: $overrideJson, ') ..write('createdAtLocal: $createdAtLocal, ') ..write('updatedAtLocal: $updatedAtLocal, ') ..write('rowid: $rowid') @@ -22640,733 +24084,1530 @@ class NotificationScheduleCompanion } } -abstract class _$AppDatabase extends GeneratedDatabase { - _$AppDatabase(QueryExecutor e) : super(e); - $AppDatabaseManager get managers => $AppDatabaseManager(this); - late final $AccountsTable accounts = $AccountsTable(this); - late final $DavAccountServicesTable davAccountServices = - $DavAccountServicesTable(this); - late final $DavCollectionsTable davCollections = $DavCollectionsTable(this); - late final $DavObjectsTable davObjects = $DavObjectsTable(this); - late final $DavObjectComponentsTable davObjectComponents = - $DavObjectComponentsTable(this); - late final $DavConflictSnapshotsTable davConflictSnapshots = - $DavConflictSnapshotsTable(this); - late final $TaskListsTable taskLists = $TaskListsTable(this); - late final $TasksTable tasks = $TasksTable(this); - late final $PendingOpsTable pendingOps = $PendingOpsTable(this); - late final $SyncRunsTable syncRuns = $SyncRunsTable(this); - late final $CalendarSourcesTable calendarSources = $CalendarSourcesTable( - this, +class $NotificationScheduleTable extends NotificationSchedule + with TableInfo<$NotificationScheduleTable, NotificationScheduleData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $NotificationScheduleTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _accountIdMeta = const VerificationMeta( + 'accountId', ); - late final $CalendarEventsTable calendarEvents = $CalendarEventsTable(this); - late final $CalendarEventAttendeesTable calendarEventAttendees = - $CalendarEventAttendeesTable(this); - late final $CalendarEventRemindersTable calendarEventReminders = - $CalendarEventRemindersTable(this); - late final $SyncCursorsTable syncCursors = $SyncCursorsTable(this); - late final $CalendarColorsTable calendarColors = $CalendarColorsTable(this); - late final $ScheduleItemOverridesTable scheduleItemOverrides = - $ScheduleItemOverridesTable(this); - late final $NotificationScheduleTable notificationSchedule = - $NotificationScheduleTable(this); - late final TaskListsDao taskListsDao = TaskListsDao(this as AppDatabase); - late final TasksDao tasksDao = TasksDao(this as AppDatabase); - late final PendingOpsDao pendingOpsDao = PendingOpsDao(this as AppDatabase); - late final SyncRunsDao syncRunsDao = SyncRunsDao(this as AppDatabase); @override - Iterable> get allTables => - allSchemaEntities.whereType>(); + late final GeneratedColumn accountId = GeneratedColumn( + 'account_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES accounts (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _sourceTypeMeta = const VerificationMeta( + 'sourceType', + ); @override - List get allSchemaEntities => [ - accounts, - davAccountServices, - davCollections, - davObjects, - davObjectComponents, - davConflictSnapshots, - taskLists, - tasks, - pendingOps, - syncRuns, - calendarSources, - calendarEvents, - calendarEventAttendees, - calendarEventReminders, - syncCursors, - calendarColors, - scheduleItemOverrides, - notificationSchedule, + late final GeneratedColumn sourceType = GeneratedColumn( + 'source_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _sourceIdMeta = const VerificationMeta( + 'sourceId', + ); + @override + late final GeneratedColumn sourceId = GeneratedColumn( + 'source_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _scheduledAtUtcMeta = const VerificationMeta( + 'scheduledAtUtc', + ); + @override + late final GeneratedColumn scheduledAtUtc = GeneratedColumn( + 'scheduled_at_utc', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _bodyMeta = const VerificationMeta('body'); + @override + late final GeneratedColumn body = GeneratedColumn( + 'body', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _sentAtUtcMeta = const VerificationMeta( + 'sentAtUtc', + ); + @override + late final GeneratedColumn sentAtUtc = GeneratedColumn( + 'sent_at_utc', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _dismissedAtUtcMeta = const VerificationMeta( + 'dismissedAtUtc', + ); + @override + late final GeneratedColumn dismissedAtUtc = GeneratedColumn( + 'dismissed_at_utc', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _snoozedUntilUtcMeta = const VerificationMeta( + 'snoozedUntilUtc', + ); + @override + late final GeneratedColumn snoozedUntilUtc = GeneratedColumn( + 'snoozed_until_utc', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtLocalMeta = const VerificationMeta( + 'createdAtLocal', + ); + @override + late final GeneratedColumn createdAtLocal = GeneratedColumn( + 'created_at_local', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtLocalMeta = const VerificationMeta( + 'updatedAtLocal', + ); + @override + late final GeneratedColumn updatedAtLocal = GeneratedColumn( + 'updated_at_local', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + accountId, + sourceType, + sourceId, + scheduledAtUtc, + title, + body, + sentAtUtc, + dismissedAtUtc, + snoozedUntilUtc, + createdAtLocal, + updatedAtLocal, ]; @override - StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_account_services', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_collections', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_objects', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_collections', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_objects', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_objects', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_object_components', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_collections', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.update)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_objects', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.update)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('task_lists', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_collections', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('task_lists', kind: UpdateKind.update)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('tasks', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_collections', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('tasks', kind: UpdateKind.update)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_objects', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('tasks', kind: UpdateKind.update)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_object_components', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('tasks', kind: UpdateKind.update)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('pending_ops', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_collections', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('pending_ops', kind: UpdateKind.update)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_objects', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('pending_ops', kind: UpdateKind.update)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_collections', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('pending_ops', kind: UpdateKind.update)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_conflict_snapshots', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('pending_ops', kind: UpdateKind.update)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('sync_runs', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('calendar_sources', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_collections', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('calendar_sources', kind: UpdateKind.update)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('calendar_events', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'calendar_sources', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('calendar_events', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_collections', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('calendar_events', kind: UpdateKind.update)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_objects', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('calendar_events', kind: UpdateKind.update)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_object_components', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('calendar_events', kind: UpdateKind.update)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'calendar_events', - limitUpdateKind: UpdateKind.delete, - ), - result: [ - TableUpdate('calendar_event_attendees', kind: UpdateKind.delete), - ], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'calendar_events', - limitUpdateKind: UpdateKind.delete, - ), - result: [ - TableUpdate('calendar_event_reminders', kind: UpdateKind.delete), - ], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('sync_cursors', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'calendar_sources', - limitUpdateKind: UpdateKind.delete, + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'notification_schedule'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('account_id')) { + context.handle( + _accountIdMeta, + accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta), + ); + } else if (isInserting) { + context.missing(_accountIdMeta); + } + if (data.containsKey('source_type')) { + context.handle( + _sourceTypeMeta, + sourceType.isAcceptableOrUnknown(data['source_type']!, _sourceTypeMeta), + ); + } else if (isInserting) { + context.missing(_sourceTypeMeta); + } + if (data.containsKey('source_id')) { + context.handle( + _sourceIdMeta, + sourceId.isAcceptableOrUnknown(data['source_id']!, _sourceIdMeta), + ); + } else if (isInserting) { + context.missing(_sourceIdMeta); + } + if (data.containsKey('scheduled_at_utc')) { + context.handle( + _scheduledAtUtcMeta, + scheduledAtUtc.isAcceptableOrUnknown( + data['scheduled_at_utc']!, + _scheduledAtUtcMeta, + ), + ); + } else if (isInserting) { + context.missing(_scheduledAtUtcMeta); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('body')) { + context.handle( + _bodyMeta, + body.isAcceptableOrUnknown(data['body']!, _bodyMeta), + ); + } + if (data.containsKey('sent_at_utc')) { + context.handle( + _sentAtUtcMeta, + sentAtUtc.isAcceptableOrUnknown(data['sent_at_utc']!, _sentAtUtcMeta), + ); + } + if (data.containsKey('dismissed_at_utc')) { + context.handle( + _dismissedAtUtcMeta, + dismissedAtUtc.isAcceptableOrUnknown( + data['dismissed_at_utc']!, + _dismissedAtUtcMeta, + ), + ); + } + if (data.containsKey('snoozed_until_utc')) { + context.handle( + _snoozedUntilUtcMeta, + snoozedUntilUtc.isAcceptableOrUnknown( + data['snoozed_until_utc']!, + _snoozedUntilUtcMeta, + ), + ); + } + if (data.containsKey('created_at_local')) { + context.handle( + _createdAtLocalMeta, + createdAtLocal.isAcceptableOrUnknown( + data['created_at_local']!, + _createdAtLocalMeta, + ), + ); + } else if (isInserting) { + context.missing(_createdAtLocalMeta); + } + if (data.containsKey('updated_at_local')) { + context.handle( + _updatedAtLocalMeta, + updatedAtLocal.isAcceptableOrUnknown( + data['updated_at_local']!, + _updatedAtLocalMeta, + ), + ); + } else if (isInserting) { + context.missing(_updatedAtLocalMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + NotificationScheduleData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return NotificationScheduleData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + accountId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}account_id'], + )!, + sourceType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_type'], + )!, + sourceId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_id'], + )!, + scheduledAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}scheduled_at_utc'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + body: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}body'], ), - result: [TableUpdate('sync_cursors', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_collections', - limitUpdateKind: UpdateKind.delete, + sentAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}sent_at_utc'], ), - result: [TableUpdate('sync_cursors', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, + dismissedAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}dismissed_at_utc'], ), - result: [TableUpdate('schedule_item_overrides', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, + snoozedUntilUtc: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}snoozed_until_utc'], ), - result: [TableUpdate('notification_schedule', kind: UpdateKind.delete)], - ), - ]); -} - -typedef $$AccountsTableCreateCompanionBuilder = - AccountsCompanion Function({ - required String id, - required String provider, - required String authority, - required String providerAccountId, - required String credentialKind, - Value providerProfileVersion, - Value displayName, - Value email, - Value tenantId, - Value accountAvatarUrl, - Value providerMetadataJson, - Value authState, - Value calendarsEnabled, - Value tasksEnabled, - Value grantedScopes, - required String createdAtUtc, - required String updatedAtUtc, - Value lastSuccessfulSyncAtUtc, - Value lastFullSyncAtUtc, - Value rowid, - }); -typedef $$AccountsTableUpdateCompanionBuilder = - AccountsCompanion Function({ - Value id, - Value provider, - Value authority, - Value providerAccountId, - Value credentialKind, - Value providerProfileVersion, - Value displayName, - Value email, - Value tenantId, - Value accountAvatarUrl, - Value providerMetadataJson, - Value authState, - Value calendarsEnabled, - Value tasksEnabled, - Value grantedScopes, - Value createdAtUtc, - Value updatedAtUtc, - Value lastSuccessfulSyncAtUtc, - Value lastFullSyncAtUtc, - Value rowid, - }); - -final class $$AccountsTableReferences - extends BaseReferences<_$AppDatabase, $AccountsTable, Account> { - $$AccountsTableReferences(super.$_db, super.$_table, super.$_typedResult); - - static MultiTypedResultKey<$DavAccountServicesTable, List> - _davAccountServicesRefsTable(_$AppDatabase db) => - MultiTypedResultKey.fromTable( - db.davAccountServices, - aliasName: $_aliasNameGenerator( - db.accounts.id, - db.davAccountServices.accountId, - ), - ); - - $$DavAccountServicesTableProcessedTableManager get davAccountServicesRefs { - final manager = $$DavAccountServicesTableTableManager( - $_db, - $_db.davAccountServices, - ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); - - final cache = $_typedResult.readTableOrNull( - _davAccountServicesRefsTable($_db), - ); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), + createdAtLocal: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at_local'], + )!, + updatedAtLocal: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}updated_at_local'], + )!, ); } - static MultiTypedResultKey<$DavCollectionsTable, List> - _davCollectionsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.davCollections, - aliasName: $_aliasNameGenerator( - db.accounts.id, - db.davCollections.accountId, - ), - ); - - $$DavCollectionsTableProcessedTableManager get davCollectionsRefs { - final manager = $$DavCollectionsTableTableManager( - $_db, - $_db.davCollections, - ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); - - final cache = $_typedResult.readTableOrNull(_davCollectionsRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); + @override + $NotificationScheduleTable createAlias(String alias) { + return $NotificationScheduleTable(attachedDatabase, alias); } +} - static MultiTypedResultKey<$DavObjectsTable, List> - _davObjectsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.davObjects, - aliasName: $_aliasNameGenerator(db.accounts.id, db.davObjects.accountId), - ); - - $$DavObjectsTableProcessedTableManager get davObjectsRefs { - final manager = $$DavObjectsTableTableManager( - $_db, - $_db.davObjects, - ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); +class NotificationScheduleData extends DataClass + implements Insertable { + final String id; + final String accountId; + final String sourceType; + final String sourceId; + final int scheduledAtUtc; + final String title; + final String? body; + final int? sentAtUtc; + final int? dismissedAtUtc; + final int? snoozedUntilUtc; + final int createdAtLocal; + final int updatedAtLocal; + const NotificationScheduleData({ + required this.id, + required this.accountId, + required this.sourceType, + required this.sourceId, + required this.scheduledAtUtc, + required this.title, + this.body, + this.sentAtUtc, + this.dismissedAtUtc, + this.snoozedUntilUtc, + required this.createdAtLocal, + required this.updatedAtLocal, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['account_id'] = Variable(accountId); + map['source_type'] = Variable(sourceType); + map['source_id'] = Variable(sourceId); + map['scheduled_at_utc'] = Variable(scheduledAtUtc); + map['title'] = Variable(title); + if (!nullToAbsent || body != null) { + map['body'] = Variable(body); + } + if (!nullToAbsent || sentAtUtc != null) { + map['sent_at_utc'] = Variable(sentAtUtc); + } + if (!nullToAbsent || dismissedAtUtc != null) { + map['dismissed_at_utc'] = Variable(dismissedAtUtc); + } + if (!nullToAbsent || snoozedUntilUtc != null) { + map['snoozed_until_utc'] = Variable(snoozedUntilUtc); + } + map['created_at_local'] = Variable(createdAtLocal); + map['updated_at_local'] = Variable(updatedAtLocal); + return map; + } - final cache = $_typedResult.readTableOrNull(_davObjectsRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), + NotificationScheduleCompanion toCompanion(bool nullToAbsent) { + return NotificationScheduleCompanion( + id: Value(id), + accountId: Value(accountId), + sourceType: Value(sourceType), + sourceId: Value(sourceId), + scheduledAtUtc: Value(scheduledAtUtc), + title: Value(title), + body: body == null && nullToAbsent ? const Value.absent() : Value(body), + sentAtUtc: sentAtUtc == null && nullToAbsent + ? const Value.absent() + : Value(sentAtUtc), + dismissedAtUtc: dismissedAtUtc == null && nullToAbsent + ? const Value.absent() + : Value(dismissedAtUtc), + snoozedUntilUtc: snoozedUntilUtc == null && nullToAbsent + ? const Value.absent() + : Value(snoozedUntilUtc), + createdAtLocal: Value(createdAtLocal), + updatedAtLocal: Value(updatedAtLocal), ); } - static MultiTypedResultKey< - $DavConflictSnapshotsTable, - List - > - _davConflictSnapshotsRefsTable(_$AppDatabase db) => - MultiTypedResultKey.fromTable( - db.davConflictSnapshots, - aliasName: $_aliasNameGenerator( - db.accounts.id, - db.davConflictSnapshots.accountId, - ), - ); - - $$DavConflictSnapshotsTableProcessedTableManager - get davConflictSnapshotsRefs { - final manager = $$DavConflictSnapshotsTableTableManager( - $_db, - $_db.davConflictSnapshots, - ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); - - final cache = $_typedResult.readTableOrNull( - _davConflictSnapshotsRefsTable($_db), - ); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); - } - - static MultiTypedResultKey<$TaskListsTable, List> - _taskListsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.taskLists, - aliasName: $_aliasNameGenerator(db.accounts.id, db.taskLists.accountId), - ); - - $$TaskListsTableProcessedTableManager get taskListsRefs { - final manager = $$TaskListsTableTableManager( - $_db, - $_db.taskLists, - ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); - - final cache = $_typedResult.readTableOrNull(_taskListsRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); - } - - static MultiTypedResultKey<$TasksTable, List> _tasksRefsTable( - _$AppDatabase db, - ) => MultiTypedResultKey.fromTable( - db.tasks, - aliasName: $_aliasNameGenerator(db.accounts.id, db.tasks.accountId), - ); - - $$TasksTableProcessedTableManager get tasksRefs { - final manager = $$TasksTableTableManager( - $_db, - $_db.tasks, - ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); - - final cache = $_typedResult.readTableOrNull(_tasksRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), + factory NotificationScheduleData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return NotificationScheduleData( + id: serializer.fromJson(json['id']), + accountId: serializer.fromJson(json['accountId']), + sourceType: serializer.fromJson(json['sourceType']), + sourceId: serializer.fromJson(json['sourceId']), + scheduledAtUtc: serializer.fromJson(json['scheduledAtUtc']), + title: serializer.fromJson(json['title']), + body: serializer.fromJson(json['body']), + sentAtUtc: serializer.fromJson(json['sentAtUtc']), + dismissedAtUtc: serializer.fromJson(json['dismissedAtUtc']), + snoozedUntilUtc: serializer.fromJson(json['snoozedUntilUtc']), + createdAtLocal: serializer.fromJson(json['createdAtLocal']), + updatedAtLocal: serializer.fromJson(json['updatedAtLocal']), ); } - - static MultiTypedResultKey<$PendingOpsTable, List> - _pendingOpsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.pendingOps, - aliasName: $_aliasNameGenerator(db.accounts.id, db.pendingOps.accountId), - ); - - $$PendingOpsTableProcessedTableManager get pendingOpsRefs { - final manager = $$PendingOpsTableTableManager( - $_db, - $_db.pendingOps, - ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); - - final cache = $_typedResult.readTableOrNull(_pendingOpsRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'accountId': serializer.toJson(accountId), + 'sourceType': serializer.toJson(sourceType), + 'sourceId': serializer.toJson(sourceId), + 'scheduledAtUtc': serializer.toJson(scheduledAtUtc), + 'title': serializer.toJson(title), + 'body': serializer.toJson(body), + 'sentAtUtc': serializer.toJson(sentAtUtc), + 'dismissedAtUtc': serializer.toJson(dismissedAtUtc), + 'snoozedUntilUtc': serializer.toJson(snoozedUntilUtc), + 'createdAtLocal': serializer.toJson(createdAtLocal), + 'updatedAtLocal': serializer.toJson(updatedAtLocal), + }; } - static MultiTypedResultKey<$SyncRunsTable, List> _syncRunsRefsTable( - _$AppDatabase db, - ) => MultiTypedResultKey.fromTable( - db.syncRuns, - aliasName: $_aliasNameGenerator(db.accounts.id, db.syncRuns.accountId), + NotificationScheduleData copyWith({ + String? id, + String? accountId, + String? sourceType, + String? sourceId, + int? scheduledAtUtc, + String? title, + Value body = const Value.absent(), + Value sentAtUtc = const Value.absent(), + Value dismissedAtUtc = const Value.absent(), + Value snoozedUntilUtc = const Value.absent(), + int? createdAtLocal, + int? updatedAtLocal, + }) => NotificationScheduleData( + id: id ?? this.id, + accountId: accountId ?? this.accountId, + sourceType: sourceType ?? this.sourceType, + sourceId: sourceId ?? this.sourceId, + scheduledAtUtc: scheduledAtUtc ?? this.scheduledAtUtc, + title: title ?? this.title, + body: body.present ? body.value : this.body, + sentAtUtc: sentAtUtc.present ? sentAtUtc.value : this.sentAtUtc, + dismissedAtUtc: dismissedAtUtc.present + ? dismissedAtUtc.value + : this.dismissedAtUtc, + snoozedUntilUtc: snoozedUntilUtc.present + ? snoozedUntilUtc.value + : this.snoozedUntilUtc, + createdAtLocal: createdAtLocal ?? this.createdAtLocal, + updatedAtLocal: updatedAtLocal ?? this.updatedAtLocal, ); - - $$SyncRunsTableProcessedTableManager get syncRunsRefs { - final manager = $$SyncRunsTableTableManager( - $_db, - $_db.syncRuns, - ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); - - final cache = $_typedResult.readTableOrNull(_syncRunsRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), + NotificationScheduleData copyWithCompanion( + NotificationScheduleCompanion data, + ) { + return NotificationScheduleData( + id: data.id.present ? data.id.value : this.id, + accountId: data.accountId.present ? data.accountId.value : this.accountId, + sourceType: data.sourceType.present + ? data.sourceType.value + : this.sourceType, + sourceId: data.sourceId.present ? data.sourceId.value : this.sourceId, + scheduledAtUtc: data.scheduledAtUtc.present + ? data.scheduledAtUtc.value + : this.scheduledAtUtc, + title: data.title.present ? data.title.value : this.title, + body: data.body.present ? data.body.value : this.body, + sentAtUtc: data.sentAtUtc.present ? data.sentAtUtc.value : this.sentAtUtc, + dismissedAtUtc: data.dismissedAtUtc.present + ? data.dismissedAtUtc.value + : this.dismissedAtUtc, + snoozedUntilUtc: data.snoozedUntilUtc.present + ? data.snoozedUntilUtc.value + : this.snoozedUntilUtc, + createdAtLocal: data.createdAtLocal.present + ? data.createdAtLocal.value + : this.createdAtLocal, + updatedAtLocal: data.updatedAtLocal.present + ? data.updatedAtLocal.value + : this.updatedAtLocal, ); } - static MultiTypedResultKey<$CalendarSourcesTable, List> - _calendarSourcesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.calendarSources, - aliasName: $_aliasNameGenerator( - db.accounts.id, - db.calendarSources.accountId, - ), - ); - - $$CalendarSourcesTableProcessedTableManager get calendarSourcesRefs { - final manager = $$CalendarSourcesTableTableManager( - $_db, - $_db.calendarSources, - ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); - - final cache = $_typedResult.readTableOrNull( - _calendarSourcesRefsTable($_db), - ); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); + @override + String toString() { + return (StringBuffer('NotificationScheduleData(') + ..write('id: $id, ') + ..write('accountId: $accountId, ') + ..write('sourceType: $sourceType, ') + ..write('sourceId: $sourceId, ') + ..write('scheduledAtUtc: $scheduledAtUtc, ') + ..write('title: $title, ') + ..write('body: $body, ') + ..write('sentAtUtc: $sentAtUtc, ') + ..write('dismissedAtUtc: $dismissedAtUtc, ') + ..write('snoozedUntilUtc: $snoozedUntilUtc, ') + ..write('createdAtLocal: $createdAtLocal, ') + ..write('updatedAtLocal: $updatedAtLocal') + ..write(')')) + .toString(); } - static MultiTypedResultKey<$CalendarEventsTable, List> - _calendarEventsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.calendarEvents, - aliasName: $_aliasNameGenerator( - db.accounts.id, - db.calendarEvents.accountId, - ), + @override + int get hashCode => Object.hash( + id, + accountId, + sourceType, + sourceId, + scheduledAtUtc, + title, + body, + sentAtUtc, + dismissedAtUtc, + snoozedUntilUtc, + createdAtLocal, + updatedAtLocal, ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is NotificationScheduleData && + other.id == this.id && + other.accountId == this.accountId && + other.sourceType == this.sourceType && + other.sourceId == this.sourceId && + other.scheduledAtUtc == this.scheduledAtUtc && + other.title == this.title && + other.body == this.body && + other.sentAtUtc == this.sentAtUtc && + other.dismissedAtUtc == this.dismissedAtUtc && + other.snoozedUntilUtc == this.snoozedUntilUtc && + other.createdAtLocal == this.createdAtLocal && + other.updatedAtLocal == this.updatedAtLocal); +} - $$CalendarEventsTableProcessedTableManager get calendarEventsRefs { - final manager = $$CalendarEventsTableTableManager( - $_db, - $_db.calendarEvents, - ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); - - final cache = $_typedResult.readTableOrNull(_calendarEventsRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); +class NotificationScheduleCompanion + extends UpdateCompanion { + final Value id; + final Value accountId; + final Value sourceType; + final Value sourceId; + final Value scheduledAtUtc; + final Value title; + final Value body; + final Value sentAtUtc; + final Value dismissedAtUtc; + final Value snoozedUntilUtc; + final Value createdAtLocal; + final Value updatedAtLocal; + final Value rowid; + const NotificationScheduleCompanion({ + this.id = const Value.absent(), + this.accountId = const Value.absent(), + this.sourceType = const Value.absent(), + this.sourceId = const Value.absent(), + this.scheduledAtUtc = const Value.absent(), + this.title = const Value.absent(), + this.body = const Value.absent(), + this.sentAtUtc = const Value.absent(), + this.dismissedAtUtc = const Value.absent(), + this.snoozedUntilUtc = const Value.absent(), + this.createdAtLocal = const Value.absent(), + this.updatedAtLocal = const Value.absent(), + this.rowid = const Value.absent(), + }); + NotificationScheduleCompanion.insert({ + required String id, + required String accountId, + required String sourceType, + required String sourceId, + required int scheduledAtUtc, + required String title, + this.body = const Value.absent(), + this.sentAtUtc = const Value.absent(), + this.dismissedAtUtc = const Value.absent(), + this.snoozedUntilUtc = const Value.absent(), + required int createdAtLocal, + required int updatedAtLocal, + this.rowid = const Value.absent(), + }) : id = Value(id), + accountId = Value(accountId), + sourceType = Value(sourceType), + sourceId = Value(sourceId), + scheduledAtUtc = Value(scheduledAtUtc), + title = Value(title), + createdAtLocal = Value(createdAtLocal), + updatedAtLocal = Value(updatedAtLocal); + static Insertable custom({ + Expression? id, + Expression? accountId, + Expression? sourceType, + Expression? sourceId, + Expression? scheduledAtUtc, + Expression? title, + Expression? body, + Expression? sentAtUtc, + Expression? dismissedAtUtc, + Expression? snoozedUntilUtc, + Expression? createdAtLocal, + Expression? updatedAtLocal, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (accountId != null) 'account_id': accountId, + if (sourceType != null) 'source_type': sourceType, + if (sourceId != null) 'source_id': sourceId, + if (scheduledAtUtc != null) 'scheduled_at_utc': scheduledAtUtc, + if (title != null) 'title': title, + if (body != null) 'body': body, + if (sentAtUtc != null) 'sent_at_utc': sentAtUtc, + if (dismissedAtUtc != null) 'dismissed_at_utc': dismissedAtUtc, + if (snoozedUntilUtc != null) 'snoozed_until_utc': snoozedUntilUtc, + if (createdAtLocal != null) 'created_at_local': createdAtLocal, + if (updatedAtLocal != null) 'updated_at_local': updatedAtLocal, + if (rowid != null) 'rowid': rowid, + }); } - static MultiTypedResultKey<$SyncCursorsTable, List> - _syncCursorsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.syncCursors, - aliasName: $_aliasNameGenerator(db.accounts.id, db.syncCursors.accountId), - ); - - $$SyncCursorsTableProcessedTableManager get syncCursorsRefs { - final manager = $$SyncCursorsTableTableManager( - $_db, - $_db.syncCursors, - ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); - - final cache = $_typedResult.readTableOrNull(_syncCursorsRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), + NotificationScheduleCompanion copyWith({ + Value? id, + Value? accountId, + Value? sourceType, + Value? sourceId, + Value? scheduledAtUtc, + Value? title, + Value? body, + Value? sentAtUtc, + Value? dismissedAtUtc, + Value? snoozedUntilUtc, + Value? createdAtLocal, + Value? updatedAtLocal, + Value? rowid, + }) { + return NotificationScheduleCompanion( + id: id ?? this.id, + accountId: accountId ?? this.accountId, + sourceType: sourceType ?? this.sourceType, + sourceId: sourceId ?? this.sourceId, + scheduledAtUtc: scheduledAtUtc ?? this.scheduledAtUtc, + title: title ?? this.title, + body: body ?? this.body, + sentAtUtc: sentAtUtc ?? this.sentAtUtc, + dismissedAtUtc: dismissedAtUtc ?? this.dismissedAtUtc, + snoozedUntilUtc: snoozedUntilUtc ?? this.snoozedUntilUtc, + createdAtLocal: createdAtLocal ?? this.createdAtLocal, + updatedAtLocal: updatedAtLocal ?? this.updatedAtLocal, + rowid: rowid ?? this.rowid, ); } - static MultiTypedResultKey< - $ScheduleItemOverridesTable, - List - > - _scheduleItemOverridesRefsTable(_$AppDatabase db) => - MultiTypedResultKey.fromTable( - db.scheduleItemOverrides, - aliasName: $_aliasNameGenerator( - db.accounts.id, - db.scheduleItemOverrides.accountId, - ), - ); - - $$ScheduleItemOverridesTableProcessedTableManager - get scheduleItemOverridesRefs { - final manager = $$ScheduleItemOverridesTableTableManager( - $_db, - $_db.scheduleItemOverrides, - ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); - - final cache = $_typedResult.readTableOrNull( - _scheduleItemOverridesRefsTable($_db), - ); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (accountId.present) { + map['account_id'] = Variable(accountId.value); + } + if (sourceType.present) { + map['source_type'] = Variable(sourceType.value); + } + if (sourceId.present) { + map['source_id'] = Variable(sourceId.value); + } + if (scheduledAtUtc.present) { + map['scheduled_at_utc'] = Variable(scheduledAtUtc.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (body.present) { + map['body'] = Variable(body.value); + } + if (sentAtUtc.present) { + map['sent_at_utc'] = Variable(sentAtUtc.value); + } + if (dismissedAtUtc.present) { + map['dismissed_at_utc'] = Variable(dismissedAtUtc.value); + } + if (snoozedUntilUtc.present) { + map['snoozed_until_utc'] = Variable(snoozedUntilUtc.value); + } + if (createdAtLocal.present) { + map['created_at_local'] = Variable(createdAtLocal.value); + } + if (updatedAtLocal.present) { + map['updated_at_local'] = Variable(updatedAtLocal.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; } - static MultiTypedResultKey< - $NotificationScheduleTable, - List - > - _notificationScheduleRefsTable(_$AppDatabase db) => - MultiTypedResultKey.fromTable( - db.notificationSchedule, - aliasName: $_aliasNameGenerator( - db.accounts.id, - db.notificationSchedule.accountId, - ), - ); - - $$NotificationScheduleTableProcessedTableManager - get notificationScheduleRefs { - final manager = $$NotificationScheduleTableTableManager( - $_db, - $_db.notificationSchedule, - ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); - - final cache = $_typedResult.readTableOrNull( - _notificationScheduleRefsTable($_db), - ); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); + @override + String toString() { + return (StringBuffer('NotificationScheduleCompanion(') + ..write('id: $id, ') + ..write('accountId: $accountId, ') + ..write('sourceType: $sourceType, ') + ..write('sourceId: $sourceId, ') + ..write('scheduledAtUtc: $scheduledAtUtc, ') + ..write('title: $title, ') + ..write('body: $body, ') + ..write('sentAtUtc: $sentAtUtc, ') + ..write('dismissedAtUtc: $dismissedAtUtc, ') + ..write('snoozedUntilUtc: $snoozedUntilUtc, ') + ..write('createdAtLocal: $createdAtLocal, ') + ..write('updatedAtLocal: $updatedAtLocal, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); } } -class $$AccountsTableFilterComposer - extends Composer<_$AppDatabase, $AccountsTable> { - $$AccountsTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnFilters(column), +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $AccountsTable accounts = $AccountsTable(this); + late final $DavAccountServicesTable davAccountServices = + $DavAccountServicesTable(this); + late final $DavCollectionsTable davCollections = $DavCollectionsTable(this); + late final $DavObjectsTable davObjects = $DavObjectsTable(this); + late final $DavObjectComponentsTable davObjectComponents = + $DavObjectComponentsTable(this); + late final $DavConflictSnapshotsTable davConflictSnapshots = + $DavConflictSnapshotsTable(this); + late final $TaskListsTable taskLists = $TaskListsTable(this); + late final $TasksTable tasks = $TasksTable(this); + late final $PendingOpsTable pendingOps = $PendingOpsTable(this); + late final $SyncRunsTable syncRuns = $SyncRunsTable(this); + late final $CalendarSourcesTable calendarSources = $CalendarSourcesTable( + this, ); + late final $CalendarEventsTable calendarEvents = $CalendarEventsTable(this); + late final $IcalImportReceiptsTable icalImportReceipts = + $IcalImportReceiptsTable(this); + late final $WebCalSubscriptionsTable webCalSubscriptions = + $WebCalSubscriptionsTable(this); + late final $CalendarEventAttendeesTable calendarEventAttendees = + $CalendarEventAttendeesTable(this); + late final $CalendarEventRemindersTable calendarEventReminders = + $CalendarEventRemindersTable(this); + late final $SyncCursorsTable syncCursors = $SyncCursorsTable(this); + late final $CalendarColorsTable calendarColors = $CalendarColorsTable(this); + late final $ScheduleItemOverridesTable scheduleItemOverrides = + $ScheduleItemOverridesTable(this); + late final $NotificationScheduleTable notificationSchedule = + $NotificationScheduleTable(this); + late final TaskListsDao taskListsDao = TaskListsDao(this as AppDatabase); + late final TasksDao tasksDao = TasksDao(this as AppDatabase); + late final PendingOpsDao pendingOpsDao = PendingOpsDao(this as AppDatabase); + late final SyncRunsDao syncRunsDao = SyncRunsDao(this as AppDatabase); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + accounts, + davAccountServices, + davCollections, + davObjects, + davObjectComponents, + davConflictSnapshots, + taskLists, + tasks, + pendingOps, + syncRuns, + calendarSources, + calendarEvents, + icalImportReceipts, + webCalSubscriptions, + calendarEventAttendees, + calendarEventReminders, + syncCursors, + calendarColors, + scheduleItemOverrides, + notificationSchedule, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_account_services', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_collections', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_objects', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_collections', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_objects', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_objects', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_object_components', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_collections', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_objects', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('task_lists', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_collections', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('task_lists', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tasks', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_collections', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tasks', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_objects', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tasks', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_object_components', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tasks', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('pending_ops', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_collections', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('pending_ops', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_objects', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('pending_ops', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_collections', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('pending_ops', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_conflict_snapshots', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('pending_ops', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('sync_runs', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('calendar_sources', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_collections', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('calendar_sources', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('calendar_events', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'calendar_sources', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('calendar_events', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_collections', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('calendar_events', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_objects', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('calendar_events', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_object_components', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('calendar_events', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'calendar_sources', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('ical_import_receipts', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('web_cal_subscriptions', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'calendar_sources', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('web_cal_subscriptions', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'calendar_events', + limitUpdateKind: UpdateKind.delete, + ), + result: [ + TableUpdate('calendar_event_attendees', kind: UpdateKind.delete), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'calendar_events', + limitUpdateKind: UpdateKind.delete, + ), + result: [ + TableUpdate('calendar_event_reminders', kind: UpdateKind.delete), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('sync_cursors', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'calendar_sources', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('sync_cursors', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_collections', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('sync_cursors', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('schedule_item_overrides', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('notification_schedule', kind: UpdateKind.delete)], + ), + ]); +} - ColumnFilters get provider => $composableBuilder( - column: $table.provider, - builder: (column) => ColumnFilters(column), - ); +typedef $$AccountsTableCreateCompanionBuilder = + AccountsCompanion Function({ + required String id, + required String provider, + required String authority, + required String providerAccountId, + required String credentialKind, + Value providerProfileVersion, + Value displayName, + Value email, + Value tenantId, + Value accountAvatarUrl, + Value providerMetadataJson, + Value authState, + Value calendarsEnabled, + Value tasksEnabled, + Value grantedScopes, + required String createdAtUtc, + required String updatedAtUtc, + Value lastSuccessfulSyncAtUtc, + Value lastFullSyncAtUtc, + Value rowid, + }); +typedef $$AccountsTableUpdateCompanionBuilder = + AccountsCompanion Function({ + Value id, + Value provider, + Value authority, + Value providerAccountId, + Value credentialKind, + Value providerProfileVersion, + Value displayName, + Value email, + Value tenantId, + Value accountAvatarUrl, + Value providerMetadataJson, + Value authState, + Value calendarsEnabled, + Value tasksEnabled, + Value grantedScopes, + Value createdAtUtc, + Value updatedAtUtc, + Value lastSuccessfulSyncAtUtc, + Value lastFullSyncAtUtc, + Value rowid, + }); - ColumnFilters get authority => $composableBuilder( - column: $table.authority, - builder: (column) => ColumnFilters(column), - ); +final class $$AccountsTableReferences + extends BaseReferences<_$AppDatabase, $AccountsTable, Account> { + $$AccountsTableReferences(super.$_db, super.$_table, super.$_typedResult); - ColumnFilters get providerAccountId => $composableBuilder( - column: $table.providerAccountId, - builder: (column) => ColumnFilters(column), - ); + static MultiTypedResultKey<$DavAccountServicesTable, List> + _davAccountServicesRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.davAccountServices, + aliasName: $_aliasNameGenerator( + db.accounts.id, + db.davAccountServices.accountId, + ), + ); - ColumnFilters get credentialKind => $composableBuilder( - column: $table.credentialKind, - builder: (column) => ColumnFilters(column), - ); + $$DavAccountServicesTableProcessedTableManager get davAccountServicesRefs { + final manager = $$DavAccountServicesTableTableManager( + $_db, + $_db.davAccountServices, + ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); - ColumnFilters get providerProfileVersion => $composableBuilder( - column: $table.providerProfileVersion, - builder: (column) => ColumnFilters(column), - ); + final cache = $_typedResult.readTableOrNull( + _davAccountServicesRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } - ColumnFilters get displayName => $composableBuilder( - column: $table.displayName, - builder: (column) => ColumnFilters(column), + static MultiTypedResultKey<$DavCollectionsTable, List> + _davCollectionsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.davCollections, + aliasName: $_aliasNameGenerator( + db.accounts.id, + db.davCollections.accountId, + ), ); - ColumnFilters get email => $composableBuilder( - column: $table.email, - builder: (column) => ColumnFilters(column), - ); + $$DavCollectionsTableProcessedTableManager get davCollectionsRefs { + final manager = $$DavCollectionsTableTableManager( + $_db, + $_db.davCollections, + ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); - ColumnFilters get tenantId => $composableBuilder( - column: $table.tenantId, - builder: (column) => ColumnFilters(column), - ); + final cache = $_typedResult.readTableOrNull(_davCollectionsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } - ColumnFilters get accountAvatarUrl => $composableBuilder( - column: $table.accountAvatarUrl, - builder: (column) => ColumnFilters(column), + static MultiTypedResultKey<$DavObjectsTable, List> + _davObjectsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.davObjects, + aliasName: $_aliasNameGenerator(db.accounts.id, db.davObjects.accountId), ); - ColumnFilters get providerMetadataJson => $composableBuilder( - column: $table.providerMetadataJson, - builder: (column) => ColumnFilters(column), - ); + $$DavObjectsTableProcessedTableManager get davObjectsRefs { + final manager = $$DavObjectsTableTableManager( + $_db, + $_db.davObjects, + ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); - ColumnFilters get authState => $composableBuilder( - column: $table.authState, - builder: (column) => ColumnFilters(column), - ); + final cache = $_typedResult.readTableOrNull(_davObjectsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } - ColumnFilters get calendarsEnabled => $composableBuilder( - column: $table.calendarsEnabled, - builder: (column) => ColumnFilters(column), - ); + static MultiTypedResultKey< + $DavConflictSnapshotsTable, + List + > + _davConflictSnapshotsRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.davConflictSnapshots, + aliasName: $_aliasNameGenerator( + db.accounts.id, + db.davConflictSnapshots.accountId, + ), + ); - ColumnFilters get tasksEnabled => $composableBuilder( - column: $table.tasksEnabled, - builder: (column) => ColumnFilters(column), - ); + $$DavConflictSnapshotsTableProcessedTableManager + get davConflictSnapshotsRefs { + final manager = $$DavConflictSnapshotsTableTableManager( + $_db, + $_db.davConflictSnapshots, + ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); - ColumnFilters get grantedScopes => $composableBuilder( - column: $table.grantedScopes, - builder: (column) => ColumnFilters(column), - ); + final cache = $_typedResult.readTableOrNull( + _davConflictSnapshotsRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } - ColumnFilters get createdAtUtc => $composableBuilder( - column: $table.createdAtUtc, - builder: (column) => ColumnFilters(column), + static MultiTypedResultKey<$TaskListsTable, List> + _taskListsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.taskLists, + aliasName: $_aliasNameGenerator(db.accounts.id, db.taskLists.accountId), + ); + + $$TaskListsTableProcessedTableManager get taskListsRefs { + final manager = $$TaskListsTableTableManager( + $_db, + $_db.taskLists, + ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_taskListsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey<$TasksTable, List> _tasksRefsTable( + _$AppDatabase db, + ) => MultiTypedResultKey.fromTable( + db.tasks, + aliasName: $_aliasNameGenerator(db.accounts.id, db.tasks.accountId), + ); + + $$TasksTableProcessedTableManager get tasksRefs { + final manager = $$TasksTableTableManager( + $_db, + $_db.tasks, + ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_tasksRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey<$PendingOpsTable, List> + _pendingOpsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.pendingOps, + aliasName: $_aliasNameGenerator(db.accounts.id, db.pendingOps.accountId), + ); + + $$PendingOpsTableProcessedTableManager get pendingOpsRefs { + final manager = $$PendingOpsTableTableManager( + $_db, + $_db.pendingOps, + ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_pendingOpsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey<$SyncRunsTable, List> _syncRunsRefsTable( + _$AppDatabase db, + ) => MultiTypedResultKey.fromTable( + db.syncRuns, + aliasName: $_aliasNameGenerator(db.accounts.id, db.syncRuns.accountId), + ); + + $$SyncRunsTableProcessedTableManager get syncRunsRefs { + final manager = $$SyncRunsTableTableManager( + $_db, + $_db.syncRuns, + ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_syncRunsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey<$CalendarSourcesTable, List> + _calendarSourcesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.calendarSources, + aliasName: $_aliasNameGenerator( + db.accounts.id, + db.calendarSources.accountId, + ), + ); + + $$CalendarSourcesTableProcessedTableManager get calendarSourcesRefs { + final manager = $$CalendarSourcesTableTableManager( + $_db, + $_db.calendarSources, + ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull( + _calendarSourcesRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey<$CalendarEventsTable, List> + _calendarEventsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.calendarEvents, + aliasName: $_aliasNameGenerator( + db.accounts.id, + db.calendarEvents.accountId, + ), + ); + + $$CalendarEventsTableProcessedTableManager get calendarEventsRefs { + final manager = $$CalendarEventsTableTableManager( + $_db, + $_db.calendarEvents, + ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_calendarEventsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey< + $WebCalSubscriptionsTable, + List + > + _webCalSubscriptionsRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.webCalSubscriptions, + aliasName: $_aliasNameGenerator( + db.accounts.id, + db.webCalSubscriptions.accountId, + ), + ); + + $$WebCalSubscriptionsTableProcessedTableManager get webCalSubscriptionsRefs { + final manager = $$WebCalSubscriptionsTableTableManager( + $_db, + $_db.webCalSubscriptions, + ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull( + _webCalSubscriptionsRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey<$SyncCursorsTable, List> + _syncCursorsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.syncCursors, + aliasName: $_aliasNameGenerator(db.accounts.id, db.syncCursors.accountId), + ); + + $$SyncCursorsTableProcessedTableManager get syncCursorsRefs { + final manager = $$SyncCursorsTableTableManager( + $_db, + $_db.syncCursors, + ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_syncCursorsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey< + $ScheduleItemOverridesTable, + List + > + _scheduleItemOverridesRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.scheduleItemOverrides, + aliasName: $_aliasNameGenerator( + db.accounts.id, + db.scheduleItemOverrides.accountId, + ), + ); + + $$ScheduleItemOverridesTableProcessedTableManager + get scheduleItemOverridesRefs { + final manager = $$ScheduleItemOverridesTableTableManager( + $_db, + $_db.scheduleItemOverrides, + ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull( + _scheduleItemOverridesRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey< + $NotificationScheduleTable, + List + > + _notificationScheduleRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.notificationSchedule, + aliasName: $_aliasNameGenerator( + db.accounts.id, + db.notificationSchedule.accountId, + ), + ); + + $$NotificationScheduleTableProcessedTableManager + get notificationScheduleRefs { + final manager = $$NotificationScheduleTableTableManager( + $_db, + $_db.notificationSchedule, + ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull( + _notificationScheduleRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $$AccountsTableFilterComposer + extends Composer<_$AppDatabase, $AccountsTable> { + $$AccountsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get provider => $composableBuilder( + column: $table.provider, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get authority => $composableBuilder( + column: $table.authority, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get providerAccountId => $composableBuilder( + column: $table.providerAccountId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get credentialKind => $composableBuilder( + column: $table.credentialKind, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get providerProfileVersion => $composableBuilder( + column: $table.providerProfileVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get displayName => $composableBuilder( + column: $table.displayName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get email => $composableBuilder( + column: $table.email, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get tenantId => $composableBuilder( + column: $table.tenantId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get accountAvatarUrl => $composableBuilder( + column: $table.accountAvatarUrl, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get providerMetadataJson => $composableBuilder( + column: $table.providerMetadataJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get authState => $composableBuilder( + column: $table.authState, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get calendarsEnabled => $composableBuilder( + column: $table.calendarsEnabled, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get tasksEnabled => $composableBuilder( + column: $table.tasksEnabled, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get grantedScopes => $composableBuilder( + column: $table.grantedScopes, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, + builder: (column) => ColumnFilters(column), ); ColumnFilters get updatedAtUtc => $composableBuilder( @@ -23634,6 +25875,31 @@ class $$AccountsTableFilterComposer return f(composer); } + Expression webCalSubscriptionsRefs( + Expression Function($$WebCalSubscriptionsTableFilterComposer f) f, + ) { + final $$WebCalSubscriptionsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.webCalSubscriptions, + getReferencedColumn: (t) => t.accountId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WebCalSubscriptionsTableFilterComposer( + $db: $db, + $table: $db.webCalSubscriptions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + Expression syncCursorsRefs( Expression Function($$SyncCursorsTableFilterComposer f) f, ) { @@ -24160,6 +26426,32 @@ class $$AccountsTableAnnotationComposer return f(composer); } + Expression webCalSubscriptionsRefs( + Expression Function($$WebCalSubscriptionsTableAnnotationComposer a) f, + ) { + final $$WebCalSubscriptionsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.webCalSubscriptions, + getReferencedColumn: (t) => t.accountId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WebCalSubscriptionsTableAnnotationComposer( + $db: $db, + $table: $db.webCalSubscriptions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + Expression syncCursorsRefs( Expression Function($$SyncCursorsTableAnnotationComposer a) f, ) { @@ -24262,6 +26554,7 @@ class $$AccountsTableTableManager bool syncRunsRefs, bool calendarSourcesRefs, bool calendarEventsRefs, + bool webCalSubscriptionsRefs, bool syncCursorsRefs, bool scheduleItemOverridesRefs, bool notificationScheduleRefs, @@ -24386,6 +26679,7 @@ class $$AccountsTableTableManager syncRunsRefs = false, calendarSourcesRefs = false, calendarEventsRefs = false, + webCalSubscriptionsRefs = false, syncCursorsRefs = false, scheduleItemOverridesRefs = false, notificationScheduleRefs = false, @@ -24403,6 +26697,7 @@ class $$AccountsTableTableManager if (syncRunsRefs) db.syncRuns, if (calendarSourcesRefs) db.calendarSources, if (calendarEventsRefs) db.calendarEvents, + if (webCalSubscriptionsRefs) db.webCalSubscriptions, if (syncCursorsRefs) db.syncCursors, if (scheduleItemOverridesRefs) db.scheduleItemOverrides, if (notificationScheduleRefs) db.notificationSchedule, @@ -24620,6 +26915,27 @@ class $$AccountsTableTableManager ), typedResults: items, ), + if (webCalSubscriptionsRefs) + await $_getPrefetchedData< + Account, + $AccountsTable, + WebCalSubscription + >( + currentTable: table, + referencedTable: $$AccountsTableReferences + ._webCalSubscriptionsRefsTable(db), + managerFromTypedResult: (p0) => + $$AccountsTableReferences( + db, + table, + p0, + ).webCalSubscriptionsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.accountId == item.id, + ), + typedResults: items, + ), if (syncCursorsRefs) await $_getPrefetchedData< Account, @@ -24714,6 +27030,7 @@ typedef $$AccountsTableProcessedTableManager = bool syncRunsRefs, bool calendarSourcesRefs, bool calendarEventsRefs, + bool webCalSubscriptionsRefs, bool syncCursorsRefs, bool scheduleItemOverridesRefs, bool notificationScheduleRefs, @@ -24756,115 +27073,1325 @@ typedef $$DavAccountServicesTableUpdateCompanionBuilder = Value rowid, }); -final class $$DavAccountServicesTableReferences - extends - BaseReferences< - _$AppDatabase, - $DavAccountServicesTable, - DavAccountService - > { - $$DavAccountServicesTableReferences( - super.$_db, - super.$_table, - super.$_typedResult, +final class $$DavAccountServicesTableReferences + extends + BaseReferences< + _$AppDatabase, + $DavAccountServicesTable, + DavAccountService + > { + $$DavAccountServicesTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $AccountsTable _accountIdTable(_$AppDatabase db) => + db.accounts.createAlias( + $_aliasNameGenerator(db.davAccountServices.accountId, db.accounts.id), + ); + + $$AccountsTableProcessedTableManager get accountId { + final $_column = $_itemColumn('account_id')!; + + final manager = $$AccountsTableTableManager( + $_db, + $_db.accounts, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$DavAccountServicesTableFilterComposer + extends Composer<_$AppDatabase, $DavAccountServicesTable> { + $$DavAccountServicesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get canonicalServiceUri => $composableBuilder( + column: $table.canonicalServiceUri, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get canonicalOrigin => $composableBuilder( + column: $table.canonicalOrigin, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get principalHref => $composableBuilder( + column: $table.principalHref, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get calendarHomeHref => $composableBuilder( + column: $table.calendarHomeHref, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get calendarUserAddressesJson => $composableBuilder( + column: $table.calendarUserAddressesJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get scheduleInboxHref => $composableBuilder( + column: $table.scheduleInboxHref, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get scheduleOutboxHref => $composableBuilder( + column: $table.scheduleOutboxHref, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get capabilitiesJson => $composableBuilder( + column: $table.capabilitiesJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get capabilitiesSchemaVersion => $composableBuilder( + column: $table.capabilitiesSchemaVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get providerProfileVersion => $composableBuilder( + column: $table.providerProfileVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get discoveredAtUtc => $composableBuilder( + column: $table.discoveredAtUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastValidatedAtUtc => $composableBuilder( + column: $table.lastValidatedAtUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastDiscoveryErrorCode => $composableBuilder( + column: $table.lastDiscoveryErrorCode, + builder: (column) => ColumnFilters(column), + ); + + $$AccountsTableFilterComposer get accountId { + final $$AccountsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableFilterComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$DavAccountServicesTableOrderingComposer + extends Composer<_$AppDatabase, $DavAccountServicesTable> { + $$DavAccountServicesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get canonicalServiceUri => $composableBuilder( + column: $table.canonicalServiceUri, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get canonicalOrigin => $composableBuilder( + column: $table.canonicalOrigin, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get principalHref => $composableBuilder( + column: $table.principalHref, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get calendarHomeHref => $composableBuilder( + column: $table.calendarHomeHref, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get calendarUserAddressesJson => $composableBuilder( + column: $table.calendarUserAddressesJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get scheduleInboxHref => $composableBuilder( + column: $table.scheduleInboxHref, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get scheduleOutboxHref => $composableBuilder( + column: $table.scheduleOutboxHref, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get capabilitiesJson => $composableBuilder( + column: $table.capabilitiesJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get capabilitiesSchemaVersion => $composableBuilder( + column: $table.capabilitiesSchemaVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get providerProfileVersion => $composableBuilder( + column: $table.providerProfileVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get discoveredAtUtc => $composableBuilder( + column: $table.discoveredAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastValidatedAtUtc => $composableBuilder( + column: $table.lastValidatedAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastDiscoveryErrorCode => $composableBuilder( + column: $table.lastDiscoveryErrorCode, + builder: (column) => ColumnOrderings(column), + ); + + $$AccountsTableOrderingComposer get accountId { + final $$AccountsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableOrderingComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$DavAccountServicesTableAnnotationComposer + extends Composer<_$AppDatabase, $DavAccountServicesTable> { + $$DavAccountServicesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get canonicalServiceUri => $composableBuilder( + column: $table.canonicalServiceUri, + builder: (column) => column, + ); + + GeneratedColumn get canonicalOrigin => $composableBuilder( + column: $table.canonicalOrigin, + builder: (column) => column, + ); + + GeneratedColumn get principalHref => $composableBuilder( + column: $table.principalHref, + builder: (column) => column, + ); + + GeneratedColumn get calendarHomeHref => $composableBuilder( + column: $table.calendarHomeHref, + builder: (column) => column, + ); + + GeneratedColumn get calendarUserAddressesJson => $composableBuilder( + column: $table.calendarUserAddressesJson, + builder: (column) => column, + ); + + GeneratedColumn get scheduleInboxHref => $composableBuilder( + column: $table.scheduleInboxHref, + builder: (column) => column, + ); + + GeneratedColumn get scheduleOutboxHref => $composableBuilder( + column: $table.scheduleOutboxHref, + builder: (column) => column, + ); + + GeneratedColumn get capabilitiesJson => $composableBuilder( + column: $table.capabilitiesJson, + builder: (column) => column, + ); + + GeneratedColumn get capabilitiesSchemaVersion => $composableBuilder( + column: $table.capabilitiesSchemaVersion, + builder: (column) => column, + ); + + GeneratedColumn get providerProfileVersion => $composableBuilder( + column: $table.providerProfileVersion, + builder: (column) => column, + ); + + GeneratedColumn get discoveredAtUtc => $composableBuilder( + column: $table.discoveredAtUtc, + builder: (column) => column, + ); + + GeneratedColumn get lastValidatedAtUtc => $composableBuilder( + column: $table.lastValidatedAtUtc, + builder: (column) => column, + ); + + GeneratedColumn get lastDiscoveryErrorCode => $composableBuilder( + column: $table.lastDiscoveryErrorCode, + builder: (column) => column, + ); + + $$AccountsTableAnnotationComposer get accountId { + final $$AccountsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableAnnotationComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$DavAccountServicesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $DavAccountServicesTable, + DavAccountService, + $$DavAccountServicesTableFilterComposer, + $$DavAccountServicesTableOrderingComposer, + $$DavAccountServicesTableAnnotationComposer, + $$DavAccountServicesTableCreateCompanionBuilder, + $$DavAccountServicesTableUpdateCompanionBuilder, + (DavAccountService, $$DavAccountServicesTableReferences), + DavAccountService, + PrefetchHooks Function({bool accountId}) + > { + $$DavAccountServicesTableTableManager( + _$AppDatabase db, + $DavAccountServicesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$DavAccountServicesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$DavAccountServicesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$DavAccountServicesTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value accountId = const Value.absent(), + Value canonicalServiceUri = const Value.absent(), + Value canonicalOrigin = const Value.absent(), + Value principalHref = const Value.absent(), + Value calendarHomeHref = const Value.absent(), + Value calendarUserAddressesJson = const Value.absent(), + Value scheduleInboxHref = const Value.absent(), + Value scheduleOutboxHref = const Value.absent(), + Value capabilitiesJson = const Value.absent(), + Value capabilitiesSchemaVersion = const Value.absent(), + Value providerProfileVersion = const Value.absent(), + Value discoveredAtUtc = const Value.absent(), + Value lastValidatedAtUtc = const Value.absent(), + Value lastDiscoveryErrorCode = const Value.absent(), + Value rowid = const Value.absent(), + }) => DavAccountServicesCompanion( + accountId: accountId, + canonicalServiceUri: canonicalServiceUri, + canonicalOrigin: canonicalOrigin, + principalHref: principalHref, + calendarHomeHref: calendarHomeHref, + calendarUserAddressesJson: calendarUserAddressesJson, + scheduleInboxHref: scheduleInboxHref, + scheduleOutboxHref: scheduleOutboxHref, + capabilitiesJson: capabilitiesJson, + capabilitiesSchemaVersion: capabilitiesSchemaVersion, + providerProfileVersion: providerProfileVersion, + discoveredAtUtc: discoveredAtUtc, + lastValidatedAtUtc: lastValidatedAtUtc, + lastDiscoveryErrorCode: lastDiscoveryErrorCode, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String accountId, + required String canonicalServiceUri, + required String canonicalOrigin, + Value principalHref = const Value.absent(), + Value calendarHomeHref = const Value.absent(), + Value calendarUserAddressesJson = const Value.absent(), + Value scheduleInboxHref = const Value.absent(), + Value scheduleOutboxHref = const Value.absent(), + Value capabilitiesJson = const Value.absent(), + Value capabilitiesSchemaVersion = const Value.absent(), + Value providerProfileVersion = const Value.absent(), + required String discoveredAtUtc, + Value lastValidatedAtUtc = const Value.absent(), + Value lastDiscoveryErrorCode = const Value.absent(), + Value rowid = const Value.absent(), + }) => DavAccountServicesCompanion.insert( + accountId: accountId, + canonicalServiceUri: canonicalServiceUri, + canonicalOrigin: canonicalOrigin, + principalHref: principalHref, + calendarHomeHref: calendarHomeHref, + calendarUserAddressesJson: calendarUserAddressesJson, + scheduleInboxHref: scheduleInboxHref, + scheduleOutboxHref: scheduleOutboxHref, + capabilitiesJson: capabilitiesJson, + capabilitiesSchemaVersion: capabilitiesSchemaVersion, + providerProfileVersion: providerProfileVersion, + discoveredAtUtc: discoveredAtUtc, + lastValidatedAtUtc: lastValidatedAtUtc, + lastDiscoveryErrorCode: lastDiscoveryErrorCode, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$DavAccountServicesTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({accountId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (accountId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: + $$DavAccountServicesTableReferences + ._accountIdTable(db), + referencedColumn: + $$DavAccountServicesTableReferences + ._accountIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$DavAccountServicesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $DavAccountServicesTable, + DavAccountService, + $$DavAccountServicesTableFilterComposer, + $$DavAccountServicesTableOrderingComposer, + $$DavAccountServicesTableAnnotationComposer, + $$DavAccountServicesTableCreateCompanionBuilder, + $$DavAccountServicesTableUpdateCompanionBuilder, + (DavAccountService, $$DavAccountServicesTableReferences), + DavAccountService, + PrefetchHooks Function({bool accountId}) + >; +typedef $$DavCollectionsTableCreateCompanionBuilder = + DavCollectionsCompanion Function({ + required String id, + required String accountId, + required String hrefKey, + required String requestUri, + required String displayName, + Value description, + Value resourceTypesJson, + Value supportedComponentMask, + Value supportedCalendarDataJson, + Value supportedReportsJson, + Value currentUserPrivilegesJson, + Value ownerHref, + Value safeDisplayMetadataJson, + Value color, + Value sortOrder, + Value calendarTimeZone, + Value calendarTimeZoneId, + Value scheduleTransparency, + Value maximumResourceSize, + Value maximumInstances, + Value syncToken, + Value ctag, + Value readOnly, + Value eventProjectionEnabled, + Value taskProjectionEnabled, + Value eventsSelected, + Value tasksSelected, + Value serverMissing, + Value deleted, + Value lastInventoryAtUtc, + Value lastSyncAtUtc, + Value parserVersion, + Value projectionVersion, + required String createdAtUtc, + required String updatedAtUtc, + Value rowid, + }); +typedef $$DavCollectionsTableUpdateCompanionBuilder = + DavCollectionsCompanion Function({ + Value id, + Value accountId, + Value hrefKey, + Value requestUri, + Value displayName, + Value description, + Value resourceTypesJson, + Value supportedComponentMask, + Value supportedCalendarDataJson, + Value supportedReportsJson, + Value currentUserPrivilegesJson, + Value ownerHref, + Value safeDisplayMetadataJson, + Value color, + Value sortOrder, + Value calendarTimeZone, + Value calendarTimeZoneId, + Value scheduleTransparency, + Value maximumResourceSize, + Value maximumInstances, + Value syncToken, + Value ctag, + Value readOnly, + Value eventProjectionEnabled, + Value taskProjectionEnabled, + Value eventsSelected, + Value tasksSelected, + Value serverMissing, + Value deleted, + Value lastInventoryAtUtc, + Value lastSyncAtUtc, + Value parserVersion, + Value projectionVersion, + Value createdAtUtc, + Value updatedAtUtc, + Value rowid, + }); + +final class $$DavCollectionsTableReferences + extends BaseReferences<_$AppDatabase, $DavCollectionsTable, DavCollection> { + $$DavCollectionsTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $AccountsTable _accountIdTable(_$AppDatabase db) => + db.accounts.createAlias( + $_aliasNameGenerator(db.davCollections.accountId, db.accounts.id), + ); + + $$AccountsTableProcessedTableManager get accountId { + final $_column = $_itemColumn('account_id')!; + + final manager = $$AccountsTableTableManager( + $_db, + $_db.accounts, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static MultiTypedResultKey<$DavObjectsTable, List> + _davObjectsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.davObjects, + aliasName: $_aliasNameGenerator( + db.davCollections.id, + db.davObjects.collectionId, + ), + ); + + $$DavObjectsTableProcessedTableManager get davObjectsRefs { + final manager = $$DavObjectsTableTableManager( + $_db, + $_db.davObjects, + ).filter((f) => f.collectionId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_davObjectsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey< + $DavConflictSnapshotsTable, + List + > + _davConflictSnapshotsRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.davConflictSnapshots, + aliasName: $_aliasNameGenerator( + db.davCollections.id, + db.davConflictSnapshots.davCollectionId, + ), + ); + + $$DavConflictSnapshotsTableProcessedTableManager + get davConflictSnapshotsRefs { + final manager = + $$DavConflictSnapshotsTableTableManager( + $_db, + $_db.davConflictSnapshots, + ).filter( + (f) => f.davCollectionId.id.sqlEquals($_itemColumn('id')!), + ); + + final cache = $_typedResult.readTableOrNull( + _davConflictSnapshotsRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey<$TaskListsTable, List> + _taskListsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.taskLists, + aliasName: $_aliasNameGenerator( + db.davCollections.id, + db.taskLists.davCollectionId, + ), + ); + + $$TaskListsTableProcessedTableManager get taskListsRefs { + final manager = $$TaskListsTableTableManager($_db, $_db.taskLists).filter( + (f) => f.davCollectionId.id.sqlEquals($_itemColumn('id')!), + ); + + final cache = $_typedResult.readTableOrNull(_taskListsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey<$TasksTable, List> _tasksRefsTable( + _$AppDatabase db, + ) => MultiTypedResultKey.fromTable( + db.tasks, + aliasName: $_aliasNameGenerator( + db.davCollections.id, + db.tasks.davCollectionId, + ), + ); + + $$TasksTableProcessedTableManager get tasksRefs { + final manager = $$TasksTableTableManager($_db, $_db.tasks).filter( + (f) => f.davCollectionId.id.sqlEquals($_itemColumn('id')!), + ); + + final cache = $_typedResult.readTableOrNull(_tasksRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey<$CalendarSourcesTable, List> + _calendarSourcesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.calendarSources, + aliasName: $_aliasNameGenerator( + db.davCollections.id, + db.calendarSources.davCollectionId, + ), + ); + + $$CalendarSourcesTableProcessedTableManager get calendarSourcesRefs { + final manager = + $$CalendarSourcesTableTableManager($_db, $_db.calendarSources).filter( + (f) => f.davCollectionId.id.sqlEquals($_itemColumn('id')!), + ); + + final cache = $_typedResult.readTableOrNull( + _calendarSourcesRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey<$CalendarEventsTable, List> + _calendarEventsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.calendarEvents, + aliasName: $_aliasNameGenerator( + db.davCollections.id, + db.calendarEvents.davCollectionId, + ), + ); + + $$CalendarEventsTableProcessedTableManager get calendarEventsRefs { + final manager = $$CalendarEventsTableTableManager($_db, $_db.calendarEvents) + .filter( + (f) => f.davCollectionId.id.sqlEquals($_itemColumn('id')!), + ); + + final cache = $_typedResult.readTableOrNull(_calendarEventsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey<$SyncCursorsTable, List> + _syncCursorsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.syncCursors, + aliasName: $_aliasNameGenerator( + db.davCollections.id, + db.syncCursors.davCollectionId, + ), + ); + + $$SyncCursorsTableProcessedTableManager get syncCursorsRefs { + final manager = $$SyncCursorsTableTableManager($_db, $_db.syncCursors) + .filter( + (f) => f.davCollectionId.id.sqlEquals($_itemColumn('id')!), + ); + + final cache = $_typedResult.readTableOrNull(_syncCursorsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $$DavCollectionsTableFilterComposer + extends Composer<_$AppDatabase, $DavCollectionsTable> { + $$DavCollectionsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get hrefKey => $composableBuilder( + column: $table.hrefKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get requestUri => $composableBuilder( + column: $table.requestUri, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get displayName => $composableBuilder( + column: $table.displayName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get resourceTypesJson => $composableBuilder( + column: $table.resourceTypesJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get supportedComponentMask => $composableBuilder( + column: $table.supportedComponentMask, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get supportedCalendarDataJson => $composableBuilder( + column: $table.supportedCalendarDataJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get supportedReportsJson => $composableBuilder( + column: $table.supportedReportsJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get currentUserPrivilegesJson => $composableBuilder( + column: $table.currentUserPrivilegesJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get ownerHref => $composableBuilder( + column: $table.ownerHref, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get safeDisplayMetadataJson => $composableBuilder( + column: $table.safeDisplayMetadataJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get color => $composableBuilder( + column: $table.color, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sortOrder => $composableBuilder( + column: $table.sortOrder, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get calendarTimeZone => $composableBuilder( + column: $table.calendarTimeZone, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get calendarTimeZoneId => $composableBuilder( + column: $table.calendarTimeZoneId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get scheduleTransparency => $composableBuilder( + column: $table.scheduleTransparency, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get maximumResourceSize => $composableBuilder( + column: $table.maximumResourceSize, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get maximumInstances => $composableBuilder( + column: $table.maximumInstances, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get syncToken => $composableBuilder( + column: $table.syncToken, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get ctag => $composableBuilder( + column: $table.ctag, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get readOnly => $composableBuilder( + column: $table.readOnly, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get eventProjectionEnabled => $composableBuilder( + column: $table.eventProjectionEnabled, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get taskProjectionEnabled => $composableBuilder( + column: $table.taskProjectionEnabled, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get eventsSelected => $composableBuilder( + column: $table.eventsSelected, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get tasksSelected => $composableBuilder( + column: $table.tasksSelected, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get serverMissing => $composableBuilder( + column: $table.serverMissing, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get deleted => $composableBuilder( + column: $table.deleted, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastInventoryAtUtc => $composableBuilder( + column: $table.lastInventoryAtUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastSyncAtUtc => $composableBuilder( + column: $table.lastSyncAtUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get parserVersion => $composableBuilder( + column: $table.parserVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get projectionVersion => $composableBuilder( + column: $table.projectionVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, + builder: (column) => ColumnFilters(column), + ); + + $$AccountsTableFilterComposer get accountId { + final $$AccountsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableFilterComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + Expression davObjectsRefs( + Expression Function($$DavObjectsTableFilterComposer f) f, + ) { + final $$DavObjectsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.davObjects, + getReferencedColumn: (t) => t.collectionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectsTableFilterComposer( + $db: $db, + $table: $db.davObjects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression davConflictSnapshotsRefs( + Expression Function($$DavConflictSnapshotsTableFilterComposer f) f, + ) { + final $$DavConflictSnapshotsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.davConflictSnapshots, + getReferencedColumn: (t) => t.davCollectionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavConflictSnapshotsTableFilterComposer( + $db: $db, + $table: $db.davConflictSnapshots, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression taskListsRefs( + Expression Function($$TaskListsTableFilterComposer f) f, + ) { + final $$TaskListsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.taskLists, + getReferencedColumn: (t) => t.davCollectionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TaskListsTableFilterComposer( + $db: $db, + $table: $db.taskLists, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression tasksRefs( + Expression Function($$TasksTableFilterComposer f) f, + ) { + final $$TasksTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.tasks, + getReferencedColumn: (t) => t.davCollectionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TasksTableFilterComposer( + $db: $db, + $table: $db.tasks, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression calendarSourcesRefs( + Expression Function($$CalendarSourcesTableFilterComposer f) f, + ) { + final $$CalendarSourcesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarSources, + getReferencedColumn: (t) => t.davCollectionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarSourcesTableFilterComposer( + $db: $db, + $table: $db.calendarSources, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression calendarEventsRefs( + Expression Function($$CalendarEventsTableFilterComposer f) f, + ) { + final $$CalendarEventsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarEvents, + getReferencedColumn: (t) => t.davCollectionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarEventsTableFilterComposer( + $db: $db, + $table: $db.calendarEvents, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression syncCursorsRefs( + Expression Function($$SyncCursorsTableFilterComposer f) f, + ) { + final $$SyncCursorsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.syncCursors, + getReferencedColumn: (t) => t.davCollectionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$SyncCursorsTableFilterComposer( + $db: $db, + $table: $db.syncCursors, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$DavCollectionsTableOrderingComposer + extends Composer<_$AppDatabase, $DavCollectionsTable> { + $$DavCollectionsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get hrefKey => $composableBuilder( + column: $table.hrefKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get requestUri => $composableBuilder( + column: $table.requestUri, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get displayName => $composableBuilder( + column: $table.displayName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get resourceTypesJson => $composableBuilder( + column: $table.resourceTypesJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get supportedComponentMask => $composableBuilder( + column: $table.supportedComponentMask, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get supportedCalendarDataJson => $composableBuilder( + column: $table.supportedCalendarDataJson, + builder: (column) => ColumnOrderings(column), ); - static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.davAccountServices.accountId, db.accounts.id), - ); + ColumnOrderings get supportedReportsJson => $composableBuilder( + column: $table.supportedReportsJson, + builder: (column) => ColumnOrderings(column), + ); - $$AccountsTableProcessedTableManager get accountId { - final $_column = $_itemColumn('account_id')!; + ColumnOrderings get currentUserPrivilegesJson => $composableBuilder( + column: $table.currentUserPrivilegesJson, + builder: (column) => ColumnOrderings(column), + ); - final manager = $$AccountsTableTableManager( - $_db, - $_db.accounts, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } -} + ColumnOrderings get ownerHref => $composableBuilder( + column: $table.ownerHref, + builder: (column) => ColumnOrderings(column), + ); -class $$DavAccountServicesTableFilterComposer - extends Composer<_$AppDatabase, $DavAccountServicesTable> { - $$DavAccountServicesTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get canonicalServiceUri => $composableBuilder( - column: $table.canonicalServiceUri, - builder: (column) => ColumnFilters(column), + ColumnOrderings get safeDisplayMetadataJson => $composableBuilder( + column: $table.safeDisplayMetadataJson, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get canonicalOrigin => $composableBuilder( - column: $table.canonicalOrigin, - builder: (column) => ColumnFilters(column), + ColumnOrderings get color => $composableBuilder( + column: $table.color, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get principalHref => $composableBuilder( - column: $table.principalHref, - builder: (column) => ColumnFilters(column), + ColumnOrderings get sortOrder => $composableBuilder( + column: $table.sortOrder, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get calendarHomeHref => $composableBuilder( - column: $table.calendarHomeHref, - builder: (column) => ColumnFilters(column), + ColumnOrderings get calendarTimeZone => $composableBuilder( + column: $table.calendarTimeZone, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get calendarUserAddressesJson => $composableBuilder( - column: $table.calendarUserAddressesJson, - builder: (column) => ColumnFilters(column), + ColumnOrderings get calendarTimeZoneId => $composableBuilder( + column: $table.calendarTimeZoneId, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get scheduleInboxHref => $composableBuilder( - column: $table.scheduleInboxHref, - builder: (column) => ColumnFilters(column), + ColumnOrderings get scheduleTransparency => $composableBuilder( + column: $table.scheduleTransparency, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get scheduleOutboxHref => $composableBuilder( - column: $table.scheduleOutboxHref, - builder: (column) => ColumnFilters(column), + ColumnOrderings get maximumResourceSize => $composableBuilder( + column: $table.maximumResourceSize, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get capabilitiesJson => $composableBuilder( - column: $table.capabilitiesJson, - builder: (column) => ColumnFilters(column), + ColumnOrderings get maximumInstances => $composableBuilder( + column: $table.maximumInstances, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get capabilitiesSchemaVersion => $composableBuilder( - column: $table.capabilitiesSchemaVersion, - builder: (column) => ColumnFilters(column), + ColumnOrderings get syncToken => $composableBuilder( + column: $table.syncToken, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get providerProfileVersion => $composableBuilder( - column: $table.providerProfileVersion, - builder: (column) => ColumnFilters(column), + ColumnOrderings get ctag => $composableBuilder( + column: $table.ctag, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get discoveredAtUtc => $composableBuilder( - column: $table.discoveredAtUtc, - builder: (column) => ColumnFilters(column), + ColumnOrderings get readOnly => $composableBuilder( + column: $table.readOnly, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get lastValidatedAtUtc => $composableBuilder( - column: $table.lastValidatedAtUtc, - builder: (column) => ColumnFilters(column), + ColumnOrderings get eventProjectionEnabled => $composableBuilder( + column: $table.eventProjectionEnabled, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get lastDiscoveryErrorCode => $composableBuilder( - column: $table.lastDiscoveryErrorCode, - builder: (column) => ColumnFilters(column), + ColumnOrderings get taskProjectionEnabled => $composableBuilder( + column: $table.taskProjectionEnabled, + builder: (column) => ColumnOrderings(column), ); - $$AccountsTableFilterComposer get accountId { - final $$AccountsTableFilterComposer composer = $composerBuilder( + ColumnOrderings get eventsSelected => $composableBuilder( + column: $table.eventsSelected, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get tasksSelected => $composableBuilder( + column: $table.tasksSelected, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get serverMissing => $composableBuilder( + column: $table.serverMissing, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get deleted => $composableBuilder( + column: $table.deleted, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastInventoryAtUtc => $composableBuilder( + column: $table.lastInventoryAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastSyncAtUtc => $composableBuilder( + column: $table.lastSyncAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get parserVersion => $composableBuilder( + column: $table.parserVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get projectionVersion => $composableBuilder( + column: $table.projectionVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + $$AccountsTableOrderingComposer get accountId { + final $$AccountsTableOrderingComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.accountId, referencedTable: $db.accounts, @@ -24874,7 +28401,7 @@ class $$DavAccountServicesTableFilterComposer joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableFilterComposer( + }) => $$AccountsTableOrderingComposer( $db: $db, $table: $db.accounts, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -24887,82 +28414,169 @@ class $$DavAccountServicesTableFilterComposer } } -class $$DavAccountServicesTableOrderingComposer - extends Composer<_$AppDatabase, $DavAccountServicesTable> { - $$DavAccountServicesTableOrderingComposer({ +class $$DavCollectionsTableAnnotationComposer + extends Composer<_$AppDatabase, $DavCollectionsTable> { + $$DavCollectionsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get canonicalServiceUri => $composableBuilder( - column: $table.canonicalServiceUri, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get hrefKey => + $composableBuilder(column: $table.hrefKey, builder: (column) => column); + + GeneratedColumn get requestUri => $composableBuilder( + column: $table.requestUri, + builder: (column) => column, ); - ColumnOrderings get canonicalOrigin => $composableBuilder( - column: $table.canonicalOrigin, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get displayName => $composableBuilder( + column: $table.displayName, + builder: (column) => column, ); - ColumnOrderings get principalHref => $composableBuilder( - column: $table.principalHref, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get description => $composableBuilder( + column: $table.description, + builder: (column) => column, ); - ColumnOrderings get calendarHomeHref => $composableBuilder( - column: $table.calendarHomeHref, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get resourceTypesJson => $composableBuilder( + column: $table.resourceTypesJson, + builder: (column) => column, ); - ColumnOrderings get calendarUserAddressesJson => $composableBuilder( - column: $table.calendarUserAddressesJson, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get supportedComponentMask => $composableBuilder( + column: $table.supportedComponentMask, + builder: (column) => column, ); - ColumnOrderings get scheduleInboxHref => $composableBuilder( - column: $table.scheduleInboxHref, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get supportedCalendarDataJson => $composableBuilder( + column: $table.supportedCalendarDataJson, + builder: (column) => column, ); - ColumnOrderings get scheduleOutboxHref => $composableBuilder( - column: $table.scheduleOutboxHref, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get supportedReportsJson => $composableBuilder( + column: $table.supportedReportsJson, + builder: (column) => column, ); - ColumnOrderings get capabilitiesJson => $composableBuilder( - column: $table.capabilitiesJson, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get currentUserPrivilegesJson => $composableBuilder( + column: $table.currentUserPrivilegesJson, + builder: (column) => column, ); - ColumnOrderings get capabilitiesSchemaVersion => $composableBuilder( - column: $table.capabilitiesSchemaVersion, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get ownerHref => + $composableBuilder(column: $table.ownerHref, builder: (column) => column); + + GeneratedColumn get safeDisplayMetadataJson => $composableBuilder( + column: $table.safeDisplayMetadataJson, + builder: (column) => column, ); - ColumnOrderings get providerProfileVersion => $composableBuilder( - column: $table.providerProfileVersion, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get color => + $composableBuilder(column: $table.color, builder: (column) => column); + + GeneratedColumn get sortOrder => + $composableBuilder(column: $table.sortOrder, builder: (column) => column); + + GeneratedColumn get calendarTimeZone => $composableBuilder( + column: $table.calendarTimeZone, + builder: (column) => column, ); - ColumnOrderings get discoveredAtUtc => $composableBuilder( - column: $table.discoveredAtUtc, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get calendarTimeZoneId => $composableBuilder( + column: $table.calendarTimeZoneId, + builder: (column) => column, ); - ColumnOrderings get lastValidatedAtUtc => $composableBuilder( - column: $table.lastValidatedAtUtc, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get scheduleTransparency => $composableBuilder( + column: $table.scheduleTransparency, + builder: (column) => column, ); - ColumnOrderings get lastDiscoveryErrorCode => $composableBuilder( - column: $table.lastDiscoveryErrorCode, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get maximumResourceSize => $composableBuilder( + column: $table.maximumResourceSize, + builder: (column) => column, ); - $$AccountsTableOrderingComposer get accountId { - final $$AccountsTableOrderingComposer composer = $composerBuilder( + GeneratedColumn get maximumInstances => $composableBuilder( + column: $table.maximumInstances, + builder: (column) => column, + ); + + GeneratedColumn get syncToken => + $composableBuilder(column: $table.syncToken, builder: (column) => column); + + GeneratedColumn get ctag => + $composableBuilder(column: $table.ctag, builder: (column) => column); + + GeneratedColumn get readOnly => + $composableBuilder(column: $table.readOnly, builder: (column) => column); + + GeneratedColumn get eventProjectionEnabled => $composableBuilder( + column: $table.eventProjectionEnabled, + builder: (column) => column, + ); + + GeneratedColumn get taskProjectionEnabled => $composableBuilder( + column: $table.taskProjectionEnabled, + builder: (column) => column, + ); + + GeneratedColumn get eventsSelected => $composableBuilder( + column: $table.eventsSelected, + builder: (column) => column, + ); + + GeneratedColumn get tasksSelected => $composableBuilder( + column: $table.tasksSelected, + builder: (column) => column, + ); + + GeneratedColumn get serverMissing => $composableBuilder( + column: $table.serverMissing, + builder: (column) => column, + ); + + GeneratedColumn get deleted => + $composableBuilder(column: $table.deleted, builder: (column) => column); + + GeneratedColumn get lastInventoryAtUtc => $composableBuilder( + column: $table.lastInventoryAtUtc, + builder: (column) => column, + ); + + GeneratedColumn get lastSyncAtUtc => $composableBuilder( + column: $table.lastSyncAtUtc, + builder: (column) => column, + ); + + GeneratedColumn get parserVersion => $composableBuilder( + column: $table.parserVersion, + builder: (column) => column, + ); + + GeneratedColumn get projectionVersion => $composableBuilder( + column: $table.projectionVersion, + builder: (column) => column, + ); + + GeneratedColumn get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, + builder: (column) => column, + ); + + GeneratedColumn get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, + builder: (column) => column, + ); + + $$AccountsTableAnnotationComposer get accountId { + final $$AccountsTableAnnotationComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.accountId, referencedTable: $db.accounts, @@ -24972,375 +28586,686 @@ class $$DavAccountServicesTableOrderingComposer joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableOrderingComposer( + }) => $$AccountsTableAnnotationComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + Expression davObjectsRefs( + Expression Function($$DavObjectsTableAnnotationComposer a) f, + ) { + final $$DavObjectsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.davObjects, + getReferencedColumn: (t) => t.collectionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectsTableAnnotationComposer( + $db: $db, + $table: $db.davObjects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression davConflictSnapshotsRefs( + Expression Function($$DavConflictSnapshotsTableAnnotationComposer a) f, + ) { + final $$DavConflictSnapshotsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.davConflictSnapshots, + getReferencedColumn: (t) => t.davCollectionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavConflictSnapshotsTableAnnotationComposer( + $db: $db, + $table: $db.davConflictSnapshots, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression taskListsRefs( + Expression Function($$TaskListsTableAnnotationComposer a) f, + ) { + final $$TaskListsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.taskLists, + getReferencedColumn: (t) => t.davCollectionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TaskListsTableAnnotationComposer( + $db: $db, + $table: $db.taskLists, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression tasksRefs( + Expression Function($$TasksTableAnnotationComposer a) f, + ) { + final $$TasksTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.tasks, + getReferencedColumn: (t) => t.davCollectionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TasksTableAnnotationComposer( + $db: $db, + $table: $db.tasks, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression calendarSourcesRefs( + Expression Function($$CalendarSourcesTableAnnotationComposer a) f, + ) { + final $$CalendarSourcesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarSources, + getReferencedColumn: (t) => t.davCollectionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarSourcesTableAnnotationComposer( + $db: $db, + $table: $db.calendarSources, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression calendarEventsRefs( + Expression Function($$CalendarEventsTableAnnotationComposer a) f, + ) { + final $$CalendarEventsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarEvents, + getReferencedColumn: (t) => t.davCollectionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarEventsTableAnnotationComposer( $db: $db, - $table: $db.accounts, + $table: $db.calendarEvents, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, ), ); - return composer; + return f(composer); } -} - -class $$DavAccountServicesTableAnnotationComposer - extends Composer<_$AppDatabase, $DavAccountServicesTable> { - $$DavAccountServicesTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get canonicalServiceUri => $composableBuilder( - column: $table.canonicalServiceUri, - builder: (column) => column, - ); - - GeneratedColumn get canonicalOrigin => $composableBuilder( - column: $table.canonicalOrigin, - builder: (column) => column, - ); - - GeneratedColumn get principalHref => $composableBuilder( - column: $table.principalHref, - builder: (column) => column, - ); - - GeneratedColumn get calendarHomeHref => $composableBuilder( - column: $table.calendarHomeHref, - builder: (column) => column, - ); - - GeneratedColumn get calendarUserAddressesJson => $composableBuilder( - column: $table.calendarUserAddressesJson, - builder: (column) => column, - ); - - GeneratedColumn get scheduleInboxHref => $composableBuilder( - column: $table.scheduleInboxHref, - builder: (column) => column, - ); - - GeneratedColumn get scheduleOutboxHref => $composableBuilder( - column: $table.scheduleOutboxHref, - builder: (column) => column, - ); - - GeneratedColumn get capabilitiesJson => $composableBuilder( - column: $table.capabilitiesJson, - builder: (column) => column, - ); - - GeneratedColumn get capabilitiesSchemaVersion => $composableBuilder( - column: $table.capabilitiesSchemaVersion, - builder: (column) => column, - ); - - GeneratedColumn get providerProfileVersion => $composableBuilder( - column: $table.providerProfileVersion, - builder: (column) => column, - ); - - GeneratedColumn get discoveredAtUtc => $composableBuilder( - column: $table.discoveredAtUtc, - builder: (column) => column, - ); - - GeneratedColumn get lastValidatedAtUtc => $composableBuilder( - column: $table.lastValidatedAtUtc, - builder: (column) => column, - ); - - GeneratedColumn get lastDiscoveryErrorCode => $composableBuilder( - column: $table.lastDiscoveryErrorCode, - builder: (column) => column, - ); - $$AccountsTableAnnotationComposer get accountId { - final $$AccountsTableAnnotationComposer composer = $composerBuilder( + Expression syncCursorsRefs( + Expression Function($$SyncCursorsTableAnnotationComposer a) f, + ) { + final $$SyncCursorsTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, - getReferencedColumn: (t) => t.id, + getCurrentColumn: (t) => t.id, + referencedTable: $db.syncCursors, + getReferencedColumn: (t) => t.davCollectionId, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableAnnotationComposer( + }) => $$SyncCursorsTableAnnotationComposer( $db: $db, - $table: $db.accounts, + $table: $db.syncCursors, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, ), ); - return composer; + return f(composer); } } -class $$DavAccountServicesTableTableManager +class $$DavCollectionsTableTableManager extends RootTableManager< _$AppDatabase, - $DavAccountServicesTable, - DavAccountService, - $$DavAccountServicesTableFilterComposer, - $$DavAccountServicesTableOrderingComposer, - $$DavAccountServicesTableAnnotationComposer, - $$DavAccountServicesTableCreateCompanionBuilder, - $$DavAccountServicesTableUpdateCompanionBuilder, - (DavAccountService, $$DavAccountServicesTableReferences), - DavAccountService, - PrefetchHooks Function({bool accountId}) + $DavCollectionsTable, + DavCollection, + $$DavCollectionsTableFilterComposer, + $$DavCollectionsTableOrderingComposer, + $$DavCollectionsTableAnnotationComposer, + $$DavCollectionsTableCreateCompanionBuilder, + $$DavCollectionsTableUpdateCompanionBuilder, + (DavCollection, $$DavCollectionsTableReferences), + DavCollection, + PrefetchHooks Function({ + bool accountId, + bool davObjectsRefs, + bool davConflictSnapshotsRefs, + bool taskListsRefs, + bool tasksRefs, + bool calendarSourcesRefs, + bool calendarEventsRefs, + bool syncCursorsRefs, + }) > { - $$DavAccountServicesTableTableManager( + $$DavCollectionsTableTableManager( _$AppDatabase db, - $DavAccountServicesTable table, + $DavCollectionsTable table, ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$DavAccountServicesTableFilterComposer($db: db, $table: table), + $$DavCollectionsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$DavAccountServicesTableOrderingComposer($db: db, $table: table), + $$DavCollectionsTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$DavAccountServicesTableAnnotationComposer( - $db: db, - $table: table, - ), + $$DavCollectionsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ + Value id = const Value.absent(), Value accountId = const Value.absent(), - Value canonicalServiceUri = const Value.absent(), - Value canonicalOrigin = const Value.absent(), - Value principalHref = const Value.absent(), - Value calendarHomeHref = const Value.absent(), - Value calendarUserAddressesJson = const Value.absent(), - Value scheduleInboxHref = const Value.absent(), - Value scheduleOutboxHref = const Value.absent(), - Value capabilitiesJson = const Value.absent(), - Value capabilitiesSchemaVersion = const Value.absent(), - Value providerProfileVersion = const Value.absent(), - Value discoveredAtUtc = const Value.absent(), - Value lastValidatedAtUtc = const Value.absent(), - Value lastDiscoveryErrorCode = const Value.absent(), + Value hrefKey = const Value.absent(), + Value requestUri = const Value.absent(), + Value displayName = const Value.absent(), + Value description = const Value.absent(), + Value resourceTypesJson = const Value.absent(), + Value supportedComponentMask = const Value.absent(), + Value supportedCalendarDataJson = const Value.absent(), + Value supportedReportsJson = const Value.absent(), + Value currentUserPrivilegesJson = const Value.absent(), + Value ownerHref = const Value.absent(), + Value safeDisplayMetadataJson = const Value.absent(), + Value color = const Value.absent(), + Value sortOrder = const Value.absent(), + Value calendarTimeZone = const Value.absent(), + Value calendarTimeZoneId = const Value.absent(), + Value scheduleTransparency = const Value.absent(), + Value maximumResourceSize = const Value.absent(), + Value maximumInstances = const Value.absent(), + Value syncToken = const Value.absent(), + Value ctag = const Value.absent(), + Value readOnly = const Value.absent(), + Value eventProjectionEnabled = const Value.absent(), + Value taskProjectionEnabled = const Value.absent(), + Value eventsSelected = const Value.absent(), + Value tasksSelected = const Value.absent(), + Value serverMissing = const Value.absent(), + Value deleted = const Value.absent(), + Value lastInventoryAtUtc = const Value.absent(), + Value lastSyncAtUtc = const Value.absent(), + Value parserVersion = const Value.absent(), + Value projectionVersion = const Value.absent(), + Value createdAtUtc = const Value.absent(), + Value updatedAtUtc = const Value.absent(), Value rowid = const Value.absent(), - }) => DavAccountServicesCompanion( + }) => DavCollectionsCompanion( + id: id, accountId: accountId, - canonicalServiceUri: canonicalServiceUri, - canonicalOrigin: canonicalOrigin, - principalHref: principalHref, - calendarHomeHref: calendarHomeHref, - calendarUserAddressesJson: calendarUserAddressesJson, - scheduleInboxHref: scheduleInboxHref, - scheduleOutboxHref: scheduleOutboxHref, - capabilitiesJson: capabilitiesJson, - capabilitiesSchemaVersion: capabilitiesSchemaVersion, - providerProfileVersion: providerProfileVersion, - discoveredAtUtc: discoveredAtUtc, - lastValidatedAtUtc: lastValidatedAtUtc, - lastDiscoveryErrorCode: lastDiscoveryErrorCode, + hrefKey: hrefKey, + requestUri: requestUri, + displayName: displayName, + description: description, + resourceTypesJson: resourceTypesJson, + supportedComponentMask: supportedComponentMask, + supportedCalendarDataJson: supportedCalendarDataJson, + supportedReportsJson: supportedReportsJson, + currentUserPrivilegesJson: currentUserPrivilegesJson, + ownerHref: ownerHref, + safeDisplayMetadataJson: safeDisplayMetadataJson, + color: color, + sortOrder: sortOrder, + calendarTimeZone: calendarTimeZone, + calendarTimeZoneId: calendarTimeZoneId, + scheduleTransparency: scheduleTransparency, + maximumResourceSize: maximumResourceSize, + maximumInstances: maximumInstances, + syncToken: syncToken, + ctag: ctag, + readOnly: readOnly, + eventProjectionEnabled: eventProjectionEnabled, + taskProjectionEnabled: taskProjectionEnabled, + eventsSelected: eventsSelected, + tasksSelected: tasksSelected, + serverMissing: serverMissing, + deleted: deleted, + lastInventoryAtUtc: lastInventoryAtUtc, + lastSyncAtUtc: lastSyncAtUtc, + parserVersion: parserVersion, + projectionVersion: projectionVersion, + createdAtUtc: createdAtUtc, + updatedAtUtc: updatedAtUtc, rowid: rowid, ), createCompanionCallback: ({ + required String id, required String accountId, - required String canonicalServiceUri, - required String canonicalOrigin, - Value principalHref = const Value.absent(), - Value calendarHomeHref = const Value.absent(), - Value calendarUserAddressesJson = const Value.absent(), - Value scheduleInboxHref = const Value.absent(), - Value scheduleOutboxHref = const Value.absent(), - Value capabilitiesJson = const Value.absent(), - Value capabilitiesSchemaVersion = const Value.absent(), - Value providerProfileVersion = const Value.absent(), - required String discoveredAtUtc, - Value lastValidatedAtUtc = const Value.absent(), - Value lastDiscoveryErrorCode = const Value.absent(), + required String hrefKey, + required String requestUri, + required String displayName, + Value description = const Value.absent(), + Value resourceTypesJson = const Value.absent(), + Value supportedComponentMask = const Value.absent(), + Value supportedCalendarDataJson = const Value.absent(), + Value supportedReportsJson = const Value.absent(), + Value currentUserPrivilegesJson = const Value.absent(), + Value ownerHref = const Value.absent(), + Value safeDisplayMetadataJson = const Value.absent(), + Value color = const Value.absent(), + Value sortOrder = const Value.absent(), + Value calendarTimeZone = const Value.absent(), + Value calendarTimeZoneId = const Value.absent(), + Value scheduleTransparency = const Value.absent(), + Value maximumResourceSize = const Value.absent(), + Value maximumInstances = const Value.absent(), + Value syncToken = const Value.absent(), + Value ctag = const Value.absent(), + Value readOnly = const Value.absent(), + Value eventProjectionEnabled = const Value.absent(), + Value taskProjectionEnabled = const Value.absent(), + Value eventsSelected = const Value.absent(), + Value tasksSelected = const Value.absent(), + Value serverMissing = const Value.absent(), + Value deleted = const Value.absent(), + Value lastInventoryAtUtc = const Value.absent(), + Value lastSyncAtUtc = const Value.absent(), + Value parserVersion = const Value.absent(), + Value projectionVersion = const Value.absent(), + required String createdAtUtc, + required String updatedAtUtc, Value rowid = const Value.absent(), - }) => DavAccountServicesCompanion.insert( + }) => DavCollectionsCompanion.insert( + id: id, accountId: accountId, - canonicalServiceUri: canonicalServiceUri, - canonicalOrigin: canonicalOrigin, - principalHref: principalHref, - calendarHomeHref: calendarHomeHref, - calendarUserAddressesJson: calendarUserAddressesJson, - scheduleInboxHref: scheduleInboxHref, - scheduleOutboxHref: scheduleOutboxHref, - capabilitiesJson: capabilitiesJson, - capabilitiesSchemaVersion: capabilitiesSchemaVersion, - providerProfileVersion: providerProfileVersion, - discoveredAtUtc: discoveredAtUtc, - lastValidatedAtUtc: lastValidatedAtUtc, - lastDiscoveryErrorCode: lastDiscoveryErrorCode, + hrefKey: hrefKey, + requestUri: requestUri, + displayName: displayName, + description: description, + resourceTypesJson: resourceTypesJson, + supportedComponentMask: supportedComponentMask, + supportedCalendarDataJson: supportedCalendarDataJson, + supportedReportsJson: supportedReportsJson, + currentUserPrivilegesJson: currentUserPrivilegesJson, + ownerHref: ownerHref, + safeDisplayMetadataJson: safeDisplayMetadataJson, + color: color, + sortOrder: sortOrder, + calendarTimeZone: calendarTimeZone, + calendarTimeZoneId: calendarTimeZoneId, + scheduleTransparency: scheduleTransparency, + maximumResourceSize: maximumResourceSize, + maximumInstances: maximumInstances, + syncToken: syncToken, + ctag: ctag, + readOnly: readOnly, + eventProjectionEnabled: eventProjectionEnabled, + taskProjectionEnabled: taskProjectionEnabled, + eventsSelected: eventsSelected, + tasksSelected: tasksSelected, + serverMissing: serverMissing, + deleted: deleted, + lastInventoryAtUtc: lastInventoryAtUtc, + lastSyncAtUtc: lastSyncAtUtc, + parserVersion: parserVersion, + projectionVersion: projectionVersion, + createdAtUtc: createdAtUtc, + updatedAtUtc: updatedAtUtc, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( e.readTable(table), - $$DavAccountServicesTableReferences(db, table, e), + $$DavCollectionsTableReferences(db, table, e), ), ) .toList(), - prefetchHooksCallback: ({accountId = false}) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [], - addJoins: - < - T extends TableManagerState< - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic - > - >(state) { - if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$DavAccountServicesTableReferences - ._accountIdTable(db), - referencedColumn: - $$DavAccountServicesTableReferences - ._accountIdTable(db) - .id, - ) - as T; - } + prefetchHooksCallback: + ({ + accountId = false, + davObjectsRefs = false, + davConflictSnapshotsRefs = false, + taskListsRefs = false, + tasksRefs = false, + calendarSourcesRefs = false, + calendarEventsRefs = false, + syncCursorsRefs = false, + }) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (davObjectsRefs) db.davObjects, + if (davConflictSnapshotsRefs) db.davConflictSnapshots, + if (taskListsRefs) db.taskLists, + if (tasksRefs) db.tasks, + if (calendarSourcesRefs) db.calendarSources, + if (calendarEventsRefs) db.calendarEvents, + if (syncCursorsRefs) db.syncCursors, + ], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (accountId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: + $$DavCollectionsTableReferences + ._accountIdTable(db), + referencedColumn: + $$DavCollectionsTableReferences + ._accountIdTable(db) + .id, + ) + as T; + } - return state; + return state; + }, + getPrefetchedDataCallback: (items) async { + return [ + if (davObjectsRefs) + await $_getPrefetchedData< + DavCollection, + $DavCollectionsTable, + DavObject + >( + currentTable: table, + referencedTable: $$DavCollectionsTableReferences + ._davObjectsRefsTable(db), + managerFromTypedResult: (p0) => + $$DavCollectionsTableReferences( + db, + table, + p0, + ).davObjectsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.collectionId == item.id, + ), + typedResults: items, + ), + if (davConflictSnapshotsRefs) + await $_getPrefetchedData< + DavCollection, + $DavCollectionsTable, + DavConflictSnapshot + >( + currentTable: table, + referencedTable: $$DavCollectionsTableReferences + ._davConflictSnapshotsRefsTable(db), + managerFromTypedResult: (p0) => + $$DavCollectionsTableReferences( + db, + table, + p0, + ).davConflictSnapshotsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.davCollectionId == item.id, + ), + typedResults: items, + ), + if (taskListsRefs) + await $_getPrefetchedData< + DavCollection, + $DavCollectionsTable, + TaskList + >( + currentTable: table, + referencedTable: $$DavCollectionsTableReferences + ._taskListsRefsTable(db), + managerFromTypedResult: (p0) => + $$DavCollectionsTableReferences( + db, + table, + p0, + ).taskListsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.davCollectionId == item.id, + ), + typedResults: items, + ), + if (tasksRefs) + await $_getPrefetchedData< + DavCollection, + $DavCollectionsTable, + Task + >( + currentTable: table, + referencedTable: $$DavCollectionsTableReferences + ._tasksRefsTable(db), + managerFromTypedResult: (p0) => + $$DavCollectionsTableReferences( + db, + table, + p0, + ).tasksRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.davCollectionId == item.id, + ), + typedResults: items, + ), + if (calendarSourcesRefs) + await $_getPrefetchedData< + DavCollection, + $DavCollectionsTable, + CalendarSource + >( + currentTable: table, + referencedTable: $$DavCollectionsTableReferences + ._calendarSourcesRefsTable(db), + managerFromTypedResult: (p0) => + $$DavCollectionsTableReferences( + db, + table, + p0, + ).calendarSourcesRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.davCollectionId == item.id, + ), + typedResults: items, + ), + if (calendarEventsRefs) + await $_getPrefetchedData< + DavCollection, + $DavCollectionsTable, + CalendarEvent + >( + currentTable: table, + referencedTable: $$DavCollectionsTableReferences + ._calendarEventsRefsTable(db), + managerFromTypedResult: (p0) => + $$DavCollectionsTableReferences( + db, + table, + p0, + ).calendarEventsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.davCollectionId == item.id, + ), + typedResults: items, + ), + if (syncCursorsRefs) + await $_getPrefetchedData< + DavCollection, + $DavCollectionsTable, + SyncCursor + >( + currentTable: table, + referencedTable: $$DavCollectionsTableReferences + ._syncCursorsRefsTable(db), + managerFromTypedResult: (p0) => + $$DavCollectionsTableReferences( + db, + table, + p0, + ).syncCursorsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.davCollectionId == item.id, + ), + typedResults: items, + ), + ]; }, - getPrefetchedDataCallback: (items) async { - return []; + ); }, - ); - }, ), ); } -typedef $$DavAccountServicesTableProcessedTableManager = +typedef $$DavCollectionsTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $DavAccountServicesTable, - DavAccountService, - $$DavAccountServicesTableFilterComposer, - $$DavAccountServicesTableOrderingComposer, - $$DavAccountServicesTableAnnotationComposer, - $$DavAccountServicesTableCreateCompanionBuilder, - $$DavAccountServicesTableUpdateCompanionBuilder, - (DavAccountService, $$DavAccountServicesTableReferences), - DavAccountService, - PrefetchHooks Function({bool accountId}) + $DavCollectionsTable, + DavCollection, + $$DavCollectionsTableFilterComposer, + $$DavCollectionsTableOrderingComposer, + $$DavCollectionsTableAnnotationComposer, + $$DavCollectionsTableCreateCompanionBuilder, + $$DavCollectionsTableUpdateCompanionBuilder, + (DavCollection, $$DavCollectionsTableReferences), + DavCollection, + PrefetchHooks Function({ + bool accountId, + bool davObjectsRefs, + bool davConflictSnapshotsRefs, + bool taskListsRefs, + bool tasksRefs, + bool calendarSourcesRefs, + bool calendarEventsRefs, + bool syncCursorsRefs, + }) >; -typedef $$DavCollectionsTableCreateCompanionBuilder = - DavCollectionsCompanion Function({ +typedef $$DavObjectsTableCreateCompanionBuilder = + DavObjectsCompanion Function({ required String id, required String accountId, + required String collectionId, required String hrefKey, required String requestUri, - required String displayName, - Value description, - Value resourceTypesJson, - Value supportedComponentMask, - Value supportedCalendarDataJson, - Value supportedReportsJson, - Value currentUserPrivilegesJson, - Value ownerHref, - Value safeDisplayMetadataJson, - Value color, - Value sortOrder, - Value calendarTimeZone, - Value calendarTimeZoneId, - Value scheduleTransparency, - Value maximumResourceSize, - Value maximumInstances, - Value syncToken, - Value ctag, - Value readOnly, - Value eventProjectionEnabled, - Value taskProjectionEnabled, - Value eventsSelected, - Value tasksSelected, - Value serverMissing, - Value deleted, - Value lastInventoryAtUtc, - Value lastSyncAtUtc, + Value etag, + Value contentType, + Value dominantComponentType, + Value componentMask, + Value primaryUid, + required String rawIcsBody, + required String rawBodyHash, + Value semanticHash, + Value serverDeleted, + Value baselineGeneration, + required String firstSeenAtUtc, + required String lastFetchedAtUtc, + required String lastChangedAtUtc, + Value lastParseStatus, + Value lastParseErrorCode, Value parserVersion, - Value projectionVersion, - required String createdAtUtc, - required String updatedAtUtc, Value rowid, }); -typedef $$DavCollectionsTableUpdateCompanionBuilder = - DavCollectionsCompanion Function({ +typedef $$DavObjectsTableUpdateCompanionBuilder = + DavObjectsCompanion Function({ Value id, Value accountId, + Value collectionId, Value hrefKey, Value requestUri, - Value displayName, - Value description, - Value resourceTypesJson, - Value supportedComponentMask, - Value supportedCalendarDataJson, - Value supportedReportsJson, - Value currentUserPrivilegesJson, - Value ownerHref, - Value safeDisplayMetadataJson, - Value color, - Value sortOrder, - Value calendarTimeZone, - Value calendarTimeZoneId, - Value scheduleTransparency, - Value maximumResourceSize, - Value maximumInstances, - Value syncToken, - Value ctag, - Value readOnly, - Value eventProjectionEnabled, - Value taskProjectionEnabled, - Value eventsSelected, - Value tasksSelected, - Value serverMissing, - Value deleted, - Value lastInventoryAtUtc, - Value lastSyncAtUtc, + Value etag, + Value contentType, + Value dominantComponentType, + Value componentMask, + Value primaryUid, + Value rawIcsBody, + Value rawBodyHash, + Value semanticHash, + Value serverDeleted, + Value baselineGeneration, + Value firstSeenAtUtc, + Value lastFetchedAtUtc, + Value lastChangedAtUtc, + Value lastParseStatus, + Value lastParseErrorCode, Value parserVersion, - Value projectionVersion, - Value createdAtUtc, - Value updatedAtUtc, Value rowid, }); -final class $$DavCollectionsTableReferences - extends BaseReferences<_$AppDatabase, $DavCollectionsTable, DavCollection> { - $$DavCollectionsTableReferences( - super.$_db, - super.$_table, - super.$_typedResult, - ); +final class $$DavObjectsTableReferences + extends BaseReferences<_$AppDatabase, $DavObjectsTable, DavObject> { + $$DavObjectsTableReferences(super.$_db, super.$_table, super.$_typedResult); static $AccountsTable _accountIdTable(_$AppDatabase db) => db.accounts.createAlias( - $_aliasNameGenerator(db.davCollections.accountId, db.accounts.id), + $_aliasNameGenerator(db.davObjects.accountId, db.accounts.id), ); $$AccountsTableProcessedTableManager get accountId { @@ -25357,22 +29282,47 @@ final class $$DavCollectionsTableReferences ); } - static MultiTypedResultKey<$DavObjectsTable, List> - _davObjectsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.davObjects, - aliasName: $_aliasNameGenerator( - db.davCollections.id, - db.davObjects.collectionId, - ), - ); + static $DavCollectionsTable _collectionIdTable(_$AppDatabase db) => + db.davCollections.createAlias( + $_aliasNameGenerator(db.davObjects.collectionId, db.davCollections.id), + ); - $$DavObjectsTableProcessedTableManager get davObjectsRefs { - final manager = $$DavObjectsTableTableManager( + $$DavCollectionsTableProcessedTableManager get collectionId { + final $_column = $_itemColumn('collection_id')!; + + final manager = $$DavCollectionsTableTableManager( $_db, - $_db.davObjects, - ).filter((f) => f.collectionId.id.sqlEquals($_itemColumn('id')!)); + $_db.davCollections, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_collectionIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } - final cache = $_typedResult.readTableOrNull(_davObjectsRefsTable($_db)); + static MultiTypedResultKey< + $DavObjectComponentsTable, + List + > + _davObjectComponentsRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.davObjectComponents, + aliasName: $_aliasNameGenerator( + db.davObjects.id, + db.davObjectComponents.davObjectId, + ), + ); + + $$DavObjectComponentsTableProcessedTableManager get davObjectComponentsRefs { + final manager = $$DavObjectComponentsTableTableManager( + $_db, + $_db.davObjectComponents, + ).filter((f) => f.davObjectId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull( + _davObjectComponentsRefsTable($_db), + ); return ProcessedTableManager( manager.$state.copyWith(prefetchedData: cache), ); @@ -25386,20 +29336,17 @@ final class $$DavCollectionsTableReferences MultiTypedResultKey.fromTable( db.davConflictSnapshots, aliasName: $_aliasNameGenerator( - db.davCollections.id, - db.davConflictSnapshots.davCollectionId, + db.davObjects.id, + db.davConflictSnapshots.davObjectId, ), ); $$DavConflictSnapshotsTableProcessedTableManager get davConflictSnapshotsRefs { - final manager = - $$DavConflictSnapshotsTableTableManager( - $_db, - $_db.davConflictSnapshots, - ).filter( - (f) => f.davCollectionId.id.sqlEquals($_itemColumn('id')!), - ); + final manager = $$DavConflictSnapshotsTableTableManager( + $_db, + $_db.davConflictSnapshots, + ).filter((f) => f.davObjectId.id.sqlEquals($_itemColumn('id')!)); final cache = $_typedResult.readTableOrNull( _davConflictSnapshotsRefsTable($_db), @@ -25409,40 +29356,18 @@ final class $$DavCollectionsTableReferences ); } - static MultiTypedResultKey<$TaskListsTable, List> - _taskListsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.taskLists, - aliasName: $_aliasNameGenerator( - db.davCollections.id, - db.taskLists.davCollectionId, - ), - ); - - $$TaskListsTableProcessedTableManager get taskListsRefs { - final manager = $$TaskListsTableTableManager($_db, $_db.taskLists).filter( - (f) => f.davCollectionId.id.sqlEquals($_itemColumn('id')!), - ); - - final cache = $_typedResult.readTableOrNull(_taskListsRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); - } - static MultiTypedResultKey<$TasksTable, List> _tasksRefsTable( _$AppDatabase db, ) => MultiTypedResultKey.fromTable( db.tasks, - aliasName: $_aliasNameGenerator( - db.davCollections.id, - db.tasks.davCollectionId, - ), + aliasName: $_aliasNameGenerator(db.davObjects.id, db.tasks.davObjectId), ); $$TasksTableProcessedTableManager get tasksRefs { - final manager = $$TasksTableTableManager($_db, $_db.tasks).filter( - (f) => f.davCollectionId.id.sqlEquals($_itemColumn('id')!), - ); + final manager = $$TasksTableTableManager( + $_db, + $_db.tasks, + ).filter((f) => f.davObjectId.id.sqlEquals($_itemColumn('id')!)); final cache = $_typedResult.readTableOrNull(_tasksRefsTable($_db)); return ProcessedTableManager( @@ -25450,24 +29375,22 @@ final class $$DavCollectionsTableReferences ); } - static MultiTypedResultKey<$CalendarSourcesTable, List> - _calendarSourcesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.calendarSources, + static MultiTypedResultKey<$PendingOpsTable, List> + _pendingOpsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.pendingOps, aliasName: $_aliasNameGenerator( - db.davCollections.id, - db.calendarSources.davCollectionId, + db.davObjects.id, + db.pendingOps.davObjectId, ), ); - $$CalendarSourcesTableProcessedTableManager get calendarSourcesRefs { - final manager = - $$CalendarSourcesTableTableManager($_db, $_db.calendarSources).filter( - (f) => f.davCollectionId.id.sqlEquals($_itemColumn('id')!), - ); + $$PendingOpsTableProcessedTableManager get pendingOpsRefs { + final manager = $$PendingOpsTableTableManager( + $_db, + $_db.pendingOps, + ).filter((f) => f.davObjectId.id.sqlEquals($_itemColumn('id')!)); - final cache = $_typedResult.readTableOrNull( - _calendarSourcesRefsTable($_db), - ); + final cache = $_typedResult.readTableOrNull(_pendingOpsRefsTable($_db)); return ProcessedTableManager( manager.$state.copyWith(prefetchedData: cache), ); @@ -25477,48 +29400,27 @@ final class $$DavCollectionsTableReferences _calendarEventsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.calendarEvents, aliasName: $_aliasNameGenerator( - db.davCollections.id, - db.calendarEvents.davCollectionId, + db.davObjects.id, + db.calendarEvents.davObjectId, ), ); $$CalendarEventsTableProcessedTableManager get calendarEventsRefs { - final manager = $$CalendarEventsTableTableManager($_db, $_db.calendarEvents) - .filter( - (f) => f.davCollectionId.id.sqlEquals($_itemColumn('id')!), - ); + final manager = $$CalendarEventsTableTableManager( + $_db, + $_db.calendarEvents, + ).filter((f) => f.davObjectId.id.sqlEquals($_itemColumn('id')!)); final cache = $_typedResult.readTableOrNull(_calendarEventsRefsTable($_db)); return ProcessedTableManager( manager.$state.copyWith(prefetchedData: cache), ); } - - static MultiTypedResultKey<$SyncCursorsTable, List> - _syncCursorsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.syncCursors, - aliasName: $_aliasNameGenerator( - db.davCollections.id, - db.syncCursors.davCollectionId, - ), - ); - - $$SyncCursorsTableProcessedTableManager get syncCursorsRefs { - final manager = $$SyncCursorsTableTableManager($_db, $_db.syncCursors) - .filter( - (f) => f.davCollectionId.id.sqlEquals($_itemColumn('id')!), - ); - - final cache = $_typedResult.readTableOrNull(_syncCursorsRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); - } } -class $$DavCollectionsTableFilterComposer - extends Composer<_$AppDatabase, $DavCollectionsTable> { - $$DavCollectionsTableFilterComposer({ +class $$DavObjectsTableFilterComposer + extends Composer<_$AppDatabase, $DavObjectsTable> { + $$DavObjectsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -25540,158 +29442,83 @@ class $$DavCollectionsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get displayName => $composableBuilder( - column: $table.displayName, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get description => $composableBuilder( - column: $table.description, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get resourceTypesJson => $composableBuilder( - column: $table.resourceTypesJson, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get supportedComponentMask => $composableBuilder( - column: $table.supportedComponentMask, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get supportedCalendarDataJson => $composableBuilder( - column: $table.supportedCalendarDataJson, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get supportedReportsJson => $composableBuilder( - column: $table.supportedReportsJson, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get currentUserPrivilegesJson => $composableBuilder( - column: $table.currentUserPrivilegesJson, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get ownerHref => $composableBuilder( - column: $table.ownerHref, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get safeDisplayMetadataJson => $composableBuilder( - column: $table.safeDisplayMetadataJson, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get color => $composableBuilder( - column: $table.color, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get sortOrder => $composableBuilder( - column: $table.sortOrder, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get calendarTimeZone => $composableBuilder( - column: $table.calendarTimeZone, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get calendarTimeZoneId => $composableBuilder( - column: $table.calendarTimeZoneId, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get scheduleTransparency => $composableBuilder( - column: $table.scheduleTransparency, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get maximumResourceSize => $composableBuilder( - column: $table.maximumResourceSize, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get maximumInstances => $composableBuilder( - column: $table.maximumInstances, + ColumnFilters get etag => $composableBuilder( + column: $table.etag, builder: (column) => ColumnFilters(column), ); - ColumnFilters get syncToken => $composableBuilder( - column: $table.syncToken, + ColumnFilters get contentType => $composableBuilder( + column: $table.contentType, builder: (column) => ColumnFilters(column), ); - ColumnFilters get ctag => $composableBuilder( - column: $table.ctag, + ColumnFilters get dominantComponentType => $composableBuilder( + column: $table.dominantComponentType, builder: (column) => ColumnFilters(column), ); - ColumnFilters get readOnly => $composableBuilder( - column: $table.readOnly, + ColumnFilters get componentMask => $composableBuilder( + column: $table.componentMask, builder: (column) => ColumnFilters(column), ); - ColumnFilters get eventProjectionEnabled => $composableBuilder( - column: $table.eventProjectionEnabled, + ColumnFilters get primaryUid => $composableBuilder( + column: $table.primaryUid, builder: (column) => ColumnFilters(column), ); - ColumnFilters get taskProjectionEnabled => $composableBuilder( - column: $table.taskProjectionEnabled, + ColumnFilters get rawIcsBody => $composableBuilder( + column: $table.rawIcsBody, builder: (column) => ColumnFilters(column), ); - ColumnFilters get eventsSelected => $composableBuilder( - column: $table.eventsSelected, + ColumnFilters get rawBodyHash => $composableBuilder( + column: $table.rawBodyHash, builder: (column) => ColumnFilters(column), ); - ColumnFilters get tasksSelected => $composableBuilder( - column: $table.tasksSelected, + ColumnFilters get semanticHash => $composableBuilder( + column: $table.semanticHash, builder: (column) => ColumnFilters(column), ); - ColumnFilters get serverMissing => $composableBuilder( - column: $table.serverMissing, + ColumnFilters get serverDeleted => $composableBuilder( + column: $table.serverDeleted, builder: (column) => ColumnFilters(column), ); - ColumnFilters get deleted => $composableBuilder( - column: $table.deleted, + ColumnFilters get baselineGeneration => $composableBuilder( + column: $table.baselineGeneration, builder: (column) => ColumnFilters(column), ); - ColumnFilters get lastInventoryAtUtc => $composableBuilder( - column: $table.lastInventoryAtUtc, + ColumnFilters get firstSeenAtUtc => $composableBuilder( + column: $table.firstSeenAtUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get lastSyncAtUtc => $composableBuilder( - column: $table.lastSyncAtUtc, + ColumnFilters get lastFetchedAtUtc => $composableBuilder( + column: $table.lastFetchedAtUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get parserVersion => $composableBuilder( - column: $table.parserVersion, + ColumnFilters get lastChangedAtUtc => $composableBuilder( + column: $table.lastChangedAtUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get projectionVersion => $composableBuilder( - column: $table.projectionVersion, + ColumnFilters get lastParseStatus => $composableBuilder( + column: $table.lastParseStatus, builder: (column) => ColumnFilters(column), ); - ColumnFilters get createdAtUtc => $composableBuilder( - column: $table.createdAtUtc, + ColumnFilters get lastParseErrorCode => $composableBuilder( + column: $table.lastParseErrorCode, builder: (column) => ColumnFilters(column), ); - ColumnFilters get updatedAtUtc => $composableBuilder( - column: $table.updatedAtUtc, + ColumnFilters get parserVersion => $composableBuilder( + column: $table.parserVersion, builder: (column) => ColumnFilters(column), ); @@ -25718,47 +29545,45 @@ class $$DavCollectionsTableFilterComposer return composer; } - Expression davObjectsRefs( - Expression Function($$DavObjectsTableFilterComposer f) f, - ) { - final $$DavObjectsTableFilterComposer composer = $composerBuilder( + $$DavCollectionsTableFilterComposer get collectionId { + final $$DavCollectionsTableFilterComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.davObjects, - getReferencedColumn: (t) => t.collectionId, + getCurrentColumn: (t) => t.collectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableFilterComposer( + }) => $$DavCollectionsTableFilterComposer( $db: $db, - $table: $db.davObjects, + $table: $db.davCollections, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, ), ); - return f(composer); + return composer; } - Expression davConflictSnapshotsRefs( - Expression Function($$DavConflictSnapshotsTableFilterComposer f) f, + Expression davObjectComponentsRefs( + Expression Function($$DavObjectComponentsTableFilterComposer f) f, ) { - final $$DavConflictSnapshotsTableFilterComposer composer = $composerBuilder( + final $$DavObjectComponentsTableFilterComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.id, - referencedTable: $db.davConflictSnapshots, - getReferencedColumn: (t) => t.davCollectionId, + referencedTable: $db.davObjectComponents, + getReferencedColumn: (t) => t.davObjectId, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavConflictSnapshotsTableFilterComposer( + }) => $$DavObjectComponentsTableFilterComposer( $db: $db, - $table: $db.davConflictSnapshots, + $table: $db.davObjectComponents, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -25768,22 +29593,22 @@ class $$DavCollectionsTableFilterComposer return f(composer); } - Expression taskListsRefs( - Expression Function($$TaskListsTableFilterComposer f) f, + Expression davConflictSnapshotsRefs( + Expression Function($$DavConflictSnapshotsTableFilterComposer f) f, ) { - final $$TaskListsTableFilterComposer composer = $composerBuilder( + final $$DavConflictSnapshotsTableFilterComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.id, - referencedTable: $db.taskLists, - getReferencedColumn: (t) => t.davCollectionId, + referencedTable: $db.davConflictSnapshots, + getReferencedColumn: (t) => t.davObjectId, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$TaskListsTableFilterComposer( + }) => $$DavConflictSnapshotsTableFilterComposer( $db: $db, - $table: $db.taskLists, + $table: $db.davConflictSnapshots, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -25800,7 +29625,7 @@ class $$DavCollectionsTableFilterComposer composer: this, getCurrentColumn: (t) => t.id, referencedTable: $db.tasks, - getReferencedColumn: (t) => t.davCollectionId, + getReferencedColumn: (t) => t.davObjectId, builder: ( joinBuilder, { @@ -25818,22 +29643,22 @@ class $$DavCollectionsTableFilterComposer return f(composer); } - Expression calendarSourcesRefs( - Expression Function($$CalendarSourcesTableFilterComposer f) f, + Expression pendingOpsRefs( + Expression Function($$PendingOpsTableFilterComposer f) f, ) { - final $$CalendarSourcesTableFilterComposer composer = $composerBuilder( + final $$PendingOpsTableFilterComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarSources, - getReferencedColumn: (t) => t.davCollectionId, + referencedTable: $db.pendingOps, + getReferencedColumn: (t) => t.davObjectId, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$CalendarSourcesTableFilterComposer( + }) => $$PendingOpsTableFilterComposer( $db: $db, - $table: $db.calendarSources, + $table: $db.pendingOps, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -25850,7 +29675,7 @@ class $$DavCollectionsTableFilterComposer composer: this, getCurrentColumn: (t) => t.id, referencedTable: $db.calendarEvents, - getReferencedColumn: (t) => t.davCollectionId, + getReferencedColumn: (t) => t.davObjectId, builder: ( joinBuilder, { @@ -25867,36 +29692,11 @@ class $$DavCollectionsTableFilterComposer ); return f(composer); } - - Expression syncCursorsRefs( - Expression Function($$SyncCursorsTableFilterComposer f) f, - ) { - final $$SyncCursorsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.syncCursors, - getReferencedColumn: (t) => t.davCollectionId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$SyncCursorsTableFilterComposer( - $db: $db, - $table: $db.syncCursors, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } } -class $$DavCollectionsTableOrderingComposer - extends Composer<_$AppDatabase, $DavCollectionsTable> { - $$DavCollectionsTableOrderingComposer({ +class $$DavObjectsTableOrderingComposer + extends Composer<_$AppDatabase, $DavObjectsTable> { + $$DavObjectsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -25918,138 +29718,78 @@ class $$DavCollectionsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get displayName => $composableBuilder( - column: $table.displayName, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get description => $composableBuilder( - column: $table.description, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get resourceTypesJson => $composableBuilder( - column: $table.resourceTypesJson, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get supportedComponentMask => $composableBuilder( - column: $table.supportedComponentMask, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get supportedCalendarDataJson => $composableBuilder( - column: $table.supportedCalendarDataJson, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get supportedReportsJson => $composableBuilder( - column: $table.supportedReportsJson, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get currentUserPrivilegesJson => $composableBuilder( - column: $table.currentUserPrivilegesJson, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get ownerHref => $composableBuilder( - column: $table.ownerHref, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get safeDisplayMetadataJson => $composableBuilder( - column: $table.safeDisplayMetadataJson, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get color => $composableBuilder( - column: $table.color, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get sortOrder => $composableBuilder( - column: $table.sortOrder, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get calendarTimeZone => $composableBuilder( - column: $table.calendarTimeZone, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get calendarTimeZoneId => $composableBuilder( - column: $table.calendarTimeZoneId, + ColumnOrderings get etag => $composableBuilder( + column: $table.etag, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get scheduleTransparency => $composableBuilder( - column: $table.scheduleTransparency, + ColumnOrderings get contentType => $composableBuilder( + column: $table.contentType, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get maximumResourceSize => $composableBuilder( - column: $table.maximumResourceSize, + ColumnOrderings get dominantComponentType => $composableBuilder( + column: $table.dominantComponentType, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get maximumInstances => $composableBuilder( - column: $table.maximumInstances, + ColumnOrderings get componentMask => $composableBuilder( + column: $table.componentMask, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get syncToken => $composableBuilder( - column: $table.syncToken, + ColumnOrderings get primaryUid => $composableBuilder( + column: $table.primaryUid, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get ctag => $composableBuilder( - column: $table.ctag, + ColumnOrderings get rawIcsBody => $composableBuilder( + column: $table.rawIcsBody, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get readOnly => $composableBuilder( - column: $table.readOnly, + ColumnOrderings get rawBodyHash => $composableBuilder( + column: $table.rawBodyHash, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get eventProjectionEnabled => $composableBuilder( - column: $table.eventProjectionEnabled, + ColumnOrderings get semanticHash => $composableBuilder( + column: $table.semanticHash, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get taskProjectionEnabled => $composableBuilder( - column: $table.taskProjectionEnabled, + ColumnOrderings get serverDeleted => $composableBuilder( + column: $table.serverDeleted, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get eventsSelected => $composableBuilder( - column: $table.eventsSelected, + ColumnOrderings get baselineGeneration => $composableBuilder( + column: $table.baselineGeneration, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get tasksSelected => $composableBuilder( - column: $table.tasksSelected, + ColumnOrderings get firstSeenAtUtc => $composableBuilder( + column: $table.firstSeenAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get serverMissing => $composableBuilder( - column: $table.serverMissing, + ColumnOrderings get lastFetchedAtUtc => $composableBuilder( + column: $table.lastFetchedAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get deleted => $composableBuilder( - column: $table.deleted, + ColumnOrderings get lastChangedAtUtc => $composableBuilder( + column: $table.lastChangedAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get lastInventoryAtUtc => $composableBuilder( - column: $table.lastInventoryAtUtc, + ColumnOrderings get lastParseStatus => $composableBuilder( + column: $table.lastParseStatus, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get lastSyncAtUtc => $composableBuilder( - column: $table.lastSyncAtUtc, + ColumnOrderings get lastParseErrorCode => $composableBuilder( + column: $table.lastParseErrorCode, builder: (column) => ColumnOrderings(column), ); @@ -26058,21 +29798,6 @@ class $$DavCollectionsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get projectionVersion => $composableBuilder( - column: $table.projectionVersion, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get createdAtUtc => $composableBuilder( - column: $table.createdAtUtc, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get updatedAtUtc => $composableBuilder( - column: $table.updatedAtUtc, - builder: (column) => ColumnOrderings(column), - ); - $$AccountsTableOrderingComposer get accountId { final $$AccountsTableOrderingComposer composer = $composerBuilder( composer: this, @@ -26095,11 +29820,34 @@ class $$DavCollectionsTableOrderingComposer ); return composer; } + + $$DavCollectionsTableOrderingComposer get collectionId { + final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.collectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavCollectionsTableOrderingComposer( + $db: $db, + $table: $db.davCollections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } } -class $$DavCollectionsTableAnnotationComposer - extends Composer<_$AppDatabase, $DavCollectionsTable> { - $$DavCollectionsTableAnnotationComposer({ +class $$DavObjectsTableAnnotationComposer + extends Composer<_$AppDatabase, $DavObjectsTable> { + $$DavObjectsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -26117,124 +29865,76 @@ class $$DavCollectionsTableAnnotationComposer builder: (column) => column, ); - GeneratedColumn get displayName => $composableBuilder( - column: $table.displayName, - builder: (column) => column, - ); - - GeneratedColumn get description => $composableBuilder( - column: $table.description, - builder: (column) => column, - ); - - GeneratedColumn get resourceTypesJson => $composableBuilder( - column: $table.resourceTypesJson, - builder: (column) => column, - ); - - GeneratedColumn get supportedComponentMask => $composableBuilder( - column: $table.supportedComponentMask, - builder: (column) => column, - ); - - GeneratedColumn get supportedCalendarDataJson => $composableBuilder( - column: $table.supportedCalendarDataJson, - builder: (column) => column, - ); - - GeneratedColumn get supportedReportsJson => $composableBuilder( - column: $table.supportedReportsJson, - builder: (column) => column, - ); + GeneratedColumn get etag => + $composableBuilder(column: $table.etag, builder: (column) => column); - GeneratedColumn get currentUserPrivilegesJson => $composableBuilder( - column: $table.currentUserPrivilegesJson, + GeneratedColumn get contentType => $composableBuilder( + column: $table.contentType, builder: (column) => column, ); - GeneratedColumn get ownerHref => - $composableBuilder(column: $table.ownerHref, builder: (column) => column); - - GeneratedColumn get safeDisplayMetadataJson => $composableBuilder( - column: $table.safeDisplayMetadataJson, + GeneratedColumn get dominantComponentType => $composableBuilder( + column: $table.dominantComponentType, builder: (column) => column, ); - GeneratedColumn get color => - $composableBuilder(column: $table.color, builder: (column) => column); - - GeneratedColumn get sortOrder => - $composableBuilder(column: $table.sortOrder, builder: (column) => column); - - GeneratedColumn get calendarTimeZone => $composableBuilder( - column: $table.calendarTimeZone, + GeneratedColumn get componentMask => $composableBuilder( + column: $table.componentMask, builder: (column) => column, ); - GeneratedColumn get calendarTimeZoneId => $composableBuilder( - column: $table.calendarTimeZoneId, + GeneratedColumn get primaryUid => $composableBuilder( + column: $table.primaryUid, builder: (column) => column, ); - GeneratedColumn get scheduleTransparency => $composableBuilder( - column: $table.scheduleTransparency, + GeneratedColumn get rawIcsBody => $composableBuilder( + column: $table.rawIcsBody, builder: (column) => column, ); - GeneratedColumn get maximumResourceSize => $composableBuilder( - column: $table.maximumResourceSize, + GeneratedColumn get rawBodyHash => $composableBuilder( + column: $table.rawBodyHash, builder: (column) => column, ); - GeneratedColumn get maximumInstances => $composableBuilder( - column: $table.maximumInstances, + GeneratedColumn get semanticHash => $composableBuilder( + column: $table.semanticHash, builder: (column) => column, ); - GeneratedColumn get syncToken => - $composableBuilder(column: $table.syncToken, builder: (column) => column); - - GeneratedColumn get ctag => - $composableBuilder(column: $table.ctag, builder: (column) => column); - - GeneratedColumn get readOnly => - $composableBuilder(column: $table.readOnly, builder: (column) => column); - - GeneratedColumn get eventProjectionEnabled => $composableBuilder( - column: $table.eventProjectionEnabled, + GeneratedColumn get serverDeleted => $composableBuilder( + column: $table.serverDeleted, builder: (column) => column, ); - GeneratedColumn get taskProjectionEnabled => $composableBuilder( - column: $table.taskProjectionEnabled, + GeneratedColumn get baselineGeneration => $composableBuilder( + column: $table.baselineGeneration, builder: (column) => column, ); - GeneratedColumn get eventsSelected => $composableBuilder( - column: $table.eventsSelected, + GeneratedColumn get firstSeenAtUtc => $composableBuilder( + column: $table.firstSeenAtUtc, builder: (column) => column, ); - GeneratedColumn get tasksSelected => $composableBuilder( - column: $table.tasksSelected, + GeneratedColumn get lastFetchedAtUtc => $composableBuilder( + column: $table.lastFetchedAtUtc, builder: (column) => column, ); - GeneratedColumn get serverMissing => $composableBuilder( - column: $table.serverMissing, + GeneratedColumn get lastChangedAtUtc => $composableBuilder( + column: $table.lastChangedAtUtc, builder: (column) => column, ); - GeneratedColumn get deleted => - $composableBuilder(column: $table.deleted, builder: (column) => column); - - GeneratedColumn get lastInventoryAtUtc => $composableBuilder( - column: $table.lastInventoryAtUtc, + GeneratedColumn get lastParseStatus => $composableBuilder( + column: $table.lastParseStatus, builder: (column) => column, ); - GeneratedColumn get lastSyncAtUtc => $composableBuilder( - column: $table.lastSyncAtUtc, + GeneratedColumn get lastParseErrorCode => $composableBuilder( + column: $table.lastParseErrorCode, builder: (column) => column, ); @@ -26243,21 +29943,6 @@ class $$DavCollectionsTableAnnotationComposer builder: (column) => column, ); - GeneratedColumn get projectionVersion => $composableBuilder( - column: $table.projectionVersion, - builder: (column) => column, - ); - - GeneratedColumn get createdAtUtc => $composableBuilder( - column: $table.createdAtUtc, - builder: (column) => column, - ); - - GeneratedColumn get updatedAtUtc => $composableBuilder( - column: $table.updatedAtUtc, - builder: (column) => column, - ); - $$AccountsTableAnnotationComposer get accountId { final $$AccountsTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -26281,28 +29966,52 @@ class $$DavCollectionsTableAnnotationComposer return composer; } - Expression davObjectsRefs( - Expression Function($$DavObjectsTableAnnotationComposer a) f, - ) { - final $$DavObjectsTableAnnotationComposer composer = $composerBuilder( + $$DavCollectionsTableAnnotationComposer get collectionId { + final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.davObjects, - getReferencedColumn: (t) => t.collectionId, + getCurrentColumn: (t) => t.collectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableAnnotationComposer( + }) => $$DavCollectionsTableAnnotationComposer( $db: $db, - $table: $db.davObjects, + $table: $db.davCollections, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, ), ); + return composer; + } + + Expression davObjectComponentsRefs( + Expression Function($$DavObjectComponentsTableAnnotationComposer a) f, + ) { + final $$DavObjectComponentsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.davObjectComponents, + getReferencedColumn: (t) => t.davObjectId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectComponentsTableAnnotationComposer( + $db: $db, + $table: $db.davObjectComponents, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } @@ -26314,7 +30023,7 @@ class $$DavCollectionsTableAnnotationComposer composer: this, getCurrentColumn: (t) => t.id, referencedTable: $db.davConflictSnapshots, - getReferencedColumn: (t) => t.davCollectionId, + getReferencedColumn: (t) => t.davObjectId, builder: ( joinBuilder, { @@ -26332,31 +30041,6 @@ class $$DavCollectionsTableAnnotationComposer return f(composer); } - Expression taskListsRefs( - Expression Function($$TaskListsTableAnnotationComposer a) f, - ) { - final $$TaskListsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.taskLists, - getReferencedColumn: (t) => t.davCollectionId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$TaskListsTableAnnotationComposer( - $db: $db, - $table: $db.taskLists, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } - Expression tasksRefs( Expression Function($$TasksTableAnnotationComposer a) f, ) { @@ -26364,7 +30048,7 @@ class $$DavCollectionsTableAnnotationComposer composer: this, getCurrentColumn: (t) => t.id, referencedTable: $db.tasks, - getReferencedColumn: (t) => t.davCollectionId, + getReferencedColumn: (t) => t.davObjectId, builder: ( joinBuilder, { @@ -26382,22 +30066,22 @@ class $$DavCollectionsTableAnnotationComposer return f(composer); } - Expression calendarSourcesRefs( - Expression Function($$CalendarSourcesTableAnnotationComposer a) f, + Expression pendingOpsRefs( + Expression Function($$PendingOpsTableAnnotationComposer a) f, ) { - final $$CalendarSourcesTableAnnotationComposer composer = $composerBuilder( + final $$PendingOpsTableAnnotationComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarSources, - getReferencedColumn: (t) => t.davCollectionId, + referencedTable: $db.pendingOps, + getReferencedColumn: (t) => t.davObjectId, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$CalendarSourcesTableAnnotationComposer( + }) => $$PendingOpsTableAnnotationComposer( $db: $db, - $table: $db.calendarSources, + $table: $db.pendingOps, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -26414,7 +30098,7 @@ class $$DavCollectionsTableAnnotationComposer composer: this, getCurrentColumn: (t) => t.id, referencedTable: $db.calendarEvents, - getReferencedColumn: (t) => t.davCollectionId, + getReferencedColumn: (t) => t.davObjectId, builder: ( joinBuilder, { @@ -26431,251 +30115,164 @@ class $$DavCollectionsTableAnnotationComposer ); return f(composer); } - - Expression syncCursorsRefs( - Expression Function($$SyncCursorsTableAnnotationComposer a) f, - ) { - final $$SyncCursorsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.syncCursors, - getReferencedColumn: (t) => t.davCollectionId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$SyncCursorsTableAnnotationComposer( - $db: $db, - $table: $db.syncCursors, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } } -class $$DavCollectionsTableTableManager +class $$DavObjectsTableTableManager extends RootTableManager< _$AppDatabase, - $DavCollectionsTable, - DavCollection, - $$DavCollectionsTableFilterComposer, - $$DavCollectionsTableOrderingComposer, - $$DavCollectionsTableAnnotationComposer, - $$DavCollectionsTableCreateCompanionBuilder, - $$DavCollectionsTableUpdateCompanionBuilder, - (DavCollection, $$DavCollectionsTableReferences), - DavCollection, + $DavObjectsTable, + DavObject, + $$DavObjectsTableFilterComposer, + $$DavObjectsTableOrderingComposer, + $$DavObjectsTableAnnotationComposer, + $$DavObjectsTableCreateCompanionBuilder, + $$DavObjectsTableUpdateCompanionBuilder, + (DavObject, $$DavObjectsTableReferences), + DavObject, PrefetchHooks Function({ bool accountId, - bool davObjectsRefs, + bool collectionId, + bool davObjectComponentsRefs, bool davConflictSnapshotsRefs, - bool taskListsRefs, bool tasksRefs, - bool calendarSourcesRefs, + bool pendingOpsRefs, bool calendarEventsRefs, - bool syncCursorsRefs, }) > { - $$DavCollectionsTableTableManager( - _$AppDatabase db, - $DavCollectionsTable table, - ) : super( + $$DavObjectsTableTableManager(_$AppDatabase db, $DavObjectsTable table) + : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$DavCollectionsTableFilterComposer($db: db, $table: table), + $$DavObjectsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$DavCollectionsTableOrderingComposer($db: db, $table: table), + $$DavObjectsTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$DavCollectionsTableAnnotationComposer($db: db, $table: table), + $$DavObjectsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), Value accountId = const Value.absent(), + Value collectionId = const Value.absent(), Value hrefKey = const Value.absent(), Value requestUri = const Value.absent(), - Value displayName = const Value.absent(), - Value description = const Value.absent(), - Value resourceTypesJson = const Value.absent(), - Value supportedComponentMask = const Value.absent(), - Value supportedCalendarDataJson = const Value.absent(), - Value supportedReportsJson = const Value.absent(), - Value currentUserPrivilegesJson = const Value.absent(), - Value ownerHref = const Value.absent(), - Value safeDisplayMetadataJson = const Value.absent(), - Value color = const Value.absent(), - Value sortOrder = const Value.absent(), - Value calendarTimeZone = const Value.absent(), - Value calendarTimeZoneId = const Value.absent(), - Value scheduleTransparency = const Value.absent(), - Value maximumResourceSize = const Value.absent(), - Value maximumInstances = const Value.absent(), - Value syncToken = const Value.absent(), - Value ctag = const Value.absent(), - Value readOnly = const Value.absent(), - Value eventProjectionEnabled = const Value.absent(), - Value taskProjectionEnabled = const Value.absent(), - Value eventsSelected = const Value.absent(), - Value tasksSelected = const Value.absent(), - Value serverMissing = const Value.absent(), - Value deleted = const Value.absent(), - Value lastInventoryAtUtc = const Value.absent(), - Value lastSyncAtUtc = const Value.absent(), + Value etag = const Value.absent(), + Value contentType = const Value.absent(), + Value dominantComponentType = const Value.absent(), + Value componentMask = const Value.absent(), + Value primaryUid = const Value.absent(), + Value rawIcsBody = const Value.absent(), + Value rawBodyHash = const Value.absent(), + Value semanticHash = const Value.absent(), + Value serverDeleted = const Value.absent(), + Value baselineGeneration = const Value.absent(), + Value firstSeenAtUtc = const Value.absent(), + Value lastFetchedAtUtc = const Value.absent(), + Value lastChangedAtUtc = const Value.absent(), + Value lastParseStatus = const Value.absent(), + Value lastParseErrorCode = const Value.absent(), Value parserVersion = const Value.absent(), - Value projectionVersion = const Value.absent(), - Value createdAtUtc = const Value.absent(), - Value updatedAtUtc = const Value.absent(), Value rowid = const Value.absent(), - }) => DavCollectionsCompanion( + }) => DavObjectsCompanion( id: id, accountId: accountId, + collectionId: collectionId, hrefKey: hrefKey, requestUri: requestUri, - displayName: displayName, - description: description, - resourceTypesJson: resourceTypesJson, - supportedComponentMask: supportedComponentMask, - supportedCalendarDataJson: supportedCalendarDataJson, - supportedReportsJson: supportedReportsJson, - currentUserPrivilegesJson: currentUserPrivilegesJson, - ownerHref: ownerHref, - safeDisplayMetadataJson: safeDisplayMetadataJson, - color: color, - sortOrder: sortOrder, - calendarTimeZone: calendarTimeZone, - calendarTimeZoneId: calendarTimeZoneId, - scheduleTransparency: scheduleTransparency, - maximumResourceSize: maximumResourceSize, - maximumInstances: maximumInstances, - syncToken: syncToken, - ctag: ctag, - readOnly: readOnly, - eventProjectionEnabled: eventProjectionEnabled, - taskProjectionEnabled: taskProjectionEnabled, - eventsSelected: eventsSelected, - tasksSelected: tasksSelected, - serverMissing: serverMissing, - deleted: deleted, - lastInventoryAtUtc: lastInventoryAtUtc, - lastSyncAtUtc: lastSyncAtUtc, + etag: etag, + contentType: contentType, + dominantComponentType: dominantComponentType, + componentMask: componentMask, + primaryUid: primaryUid, + rawIcsBody: rawIcsBody, + rawBodyHash: rawBodyHash, + semanticHash: semanticHash, + serverDeleted: serverDeleted, + baselineGeneration: baselineGeneration, + firstSeenAtUtc: firstSeenAtUtc, + lastFetchedAtUtc: lastFetchedAtUtc, + lastChangedAtUtc: lastChangedAtUtc, + lastParseStatus: lastParseStatus, + lastParseErrorCode: lastParseErrorCode, parserVersion: parserVersion, - projectionVersion: projectionVersion, - createdAtUtc: createdAtUtc, - updatedAtUtc: updatedAtUtc, rowid: rowid, ), createCompanionCallback: ({ required String id, required String accountId, + required String collectionId, required String hrefKey, required String requestUri, - required String displayName, - Value description = const Value.absent(), - Value resourceTypesJson = const Value.absent(), - Value supportedComponentMask = const Value.absent(), - Value supportedCalendarDataJson = const Value.absent(), - Value supportedReportsJson = const Value.absent(), - Value currentUserPrivilegesJson = const Value.absent(), - Value ownerHref = const Value.absent(), - Value safeDisplayMetadataJson = const Value.absent(), - Value color = const Value.absent(), - Value sortOrder = const Value.absent(), - Value calendarTimeZone = const Value.absent(), - Value calendarTimeZoneId = const Value.absent(), - Value scheduleTransparency = const Value.absent(), - Value maximumResourceSize = const Value.absent(), - Value maximumInstances = const Value.absent(), - Value syncToken = const Value.absent(), - Value ctag = const Value.absent(), - Value readOnly = const Value.absent(), - Value eventProjectionEnabled = const Value.absent(), - Value taskProjectionEnabled = const Value.absent(), - Value eventsSelected = const Value.absent(), - Value tasksSelected = const Value.absent(), - Value serverMissing = const Value.absent(), - Value deleted = const Value.absent(), - Value lastInventoryAtUtc = const Value.absent(), - Value lastSyncAtUtc = const Value.absent(), + Value etag = const Value.absent(), + Value contentType = const Value.absent(), + Value dominantComponentType = const Value.absent(), + Value componentMask = const Value.absent(), + Value primaryUid = const Value.absent(), + required String rawIcsBody, + required String rawBodyHash, + Value semanticHash = const Value.absent(), + Value serverDeleted = const Value.absent(), + Value baselineGeneration = const Value.absent(), + required String firstSeenAtUtc, + required String lastFetchedAtUtc, + required String lastChangedAtUtc, + Value lastParseStatus = const Value.absent(), + Value lastParseErrorCode = const Value.absent(), Value parserVersion = const Value.absent(), - Value projectionVersion = const Value.absent(), - required String createdAtUtc, - required String updatedAtUtc, Value rowid = const Value.absent(), - }) => DavCollectionsCompanion.insert( + }) => DavObjectsCompanion.insert( id: id, accountId: accountId, + collectionId: collectionId, hrefKey: hrefKey, requestUri: requestUri, - displayName: displayName, - description: description, - resourceTypesJson: resourceTypesJson, - supportedComponentMask: supportedComponentMask, - supportedCalendarDataJson: supportedCalendarDataJson, - supportedReportsJson: supportedReportsJson, - currentUserPrivilegesJson: currentUserPrivilegesJson, - ownerHref: ownerHref, - safeDisplayMetadataJson: safeDisplayMetadataJson, - color: color, - sortOrder: sortOrder, - calendarTimeZone: calendarTimeZone, - calendarTimeZoneId: calendarTimeZoneId, - scheduleTransparency: scheduleTransparency, - maximumResourceSize: maximumResourceSize, - maximumInstances: maximumInstances, - syncToken: syncToken, - ctag: ctag, - readOnly: readOnly, - eventProjectionEnabled: eventProjectionEnabled, - taskProjectionEnabled: taskProjectionEnabled, - eventsSelected: eventsSelected, - tasksSelected: tasksSelected, - serverMissing: serverMissing, - deleted: deleted, - lastInventoryAtUtc: lastInventoryAtUtc, - lastSyncAtUtc: lastSyncAtUtc, + etag: etag, + contentType: contentType, + dominantComponentType: dominantComponentType, + componentMask: componentMask, + primaryUid: primaryUid, + rawIcsBody: rawIcsBody, + rawBodyHash: rawBodyHash, + semanticHash: semanticHash, + serverDeleted: serverDeleted, + baselineGeneration: baselineGeneration, + firstSeenAtUtc: firstSeenAtUtc, + lastFetchedAtUtc: lastFetchedAtUtc, + lastChangedAtUtc: lastChangedAtUtc, + lastParseStatus: lastParseStatus, + lastParseErrorCode: lastParseErrorCode, parserVersion: parserVersion, - projectionVersion: projectionVersion, - createdAtUtc: createdAtUtc, - updatedAtUtc: updatedAtUtc, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( e.readTable(table), - $$DavCollectionsTableReferences(db, table, e), + $$DavObjectsTableReferences(db, table, e), ), ) .toList(), prefetchHooksCallback: ({ accountId = false, - davObjectsRefs = false, + collectionId = false, + davObjectComponentsRefs = false, davConflictSnapshotsRefs = false, - taskListsRefs = false, tasksRefs = false, - calendarSourcesRefs = false, + pendingOpsRefs = false, calendarEventsRefs = false, - syncCursorsRefs = false, }) { return PrefetchHooks( db: db, explicitlyWatchedTables: [ - if (davObjectsRefs) db.davObjects, + if (davObjectComponentsRefs) db.davObjectComponents, if (davConflictSnapshotsRefs) db.davConflictSnapshots, - if (taskListsRefs) db.taskLists, if (tasksRefs) db.tasks, - if (calendarSourcesRefs) db.calendarSources, + if (pendingOpsRefs) db.pendingOps, if (calendarEventsRefs) db.calendarEvents, - if (syncCursorsRefs) db.syncCursors, ], addJoins: < @@ -26698,165 +30295,136 @@ class $$DavCollectionsTableTableManager state.withJoin( currentTable: table, currentColumn: table.accountId, - referencedTable: - $$DavCollectionsTableReferences - ._accountIdTable(db), + referencedTable: $$DavObjectsTableReferences + ._accountIdTable(db), referencedColumn: - $$DavCollectionsTableReferences + $$DavObjectsTableReferences ._accountIdTable(db) .id, ) as T; } + if (collectionId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.collectionId, + referencedTable: $$DavObjectsTableReferences + ._collectionIdTable(db), + referencedColumn: + $$DavObjectsTableReferences + ._collectionIdTable(db) + .id, + ) + as T; + } return state; }, getPrefetchedDataCallback: (items) async { return [ - if (davObjectsRefs) + if (davObjectComponentsRefs) await $_getPrefetchedData< - DavCollection, - $DavCollectionsTable, - DavObject + DavObject, + $DavObjectsTable, + DavObjectComponent >( currentTable: table, - referencedTable: $$DavCollectionsTableReferences - ._davObjectsRefsTable(db), + referencedTable: $$DavObjectsTableReferences + ._davObjectComponentsRefsTable(db), managerFromTypedResult: (p0) => - $$DavCollectionsTableReferences( + $$DavObjectsTableReferences( db, table, p0, - ).davObjectsRefs, + ).davObjectComponentsRefs, referencedItemsForCurrentItem: (item, referencedItems) => referencedItems.where( - (e) => e.collectionId == item.id, + (e) => e.davObjectId == item.id, ), typedResults: items, ), if (davConflictSnapshotsRefs) await $_getPrefetchedData< - DavCollection, - $DavCollectionsTable, + DavObject, + $DavObjectsTable, DavConflictSnapshot >( currentTable: table, - referencedTable: $$DavCollectionsTableReferences + referencedTable: $$DavObjectsTableReferences ._davConflictSnapshotsRefsTable(db), managerFromTypedResult: (p0) => - $$DavCollectionsTableReferences( + $$DavObjectsTableReferences( db, table, p0, ).davConflictSnapshotsRefs, referencedItemsForCurrentItem: (item, referencedItems) => referencedItems.where( - (e) => e.davCollectionId == item.id, - ), - typedResults: items, - ), - if (taskListsRefs) - await $_getPrefetchedData< - DavCollection, - $DavCollectionsTable, - TaskList - >( - currentTable: table, - referencedTable: $$DavCollectionsTableReferences - ._taskListsRefsTable(db), - managerFromTypedResult: (p0) => - $$DavCollectionsTableReferences( - db, - table, - p0, - ).taskListsRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.davCollectionId == item.id, + (e) => e.davObjectId == item.id, ), typedResults: items, ), if (tasksRefs) await $_getPrefetchedData< - DavCollection, - $DavCollectionsTable, + DavObject, + $DavObjectsTable, Task >( currentTable: table, - referencedTable: $$DavCollectionsTableReferences + referencedTable: $$DavObjectsTableReferences ._tasksRefsTable(db), managerFromTypedResult: (p0) => - $$DavCollectionsTableReferences( + $$DavObjectsTableReferences( db, table, p0, ).tasksRefs, referencedItemsForCurrentItem: (item, referencedItems) => referencedItems.where( - (e) => e.davCollectionId == item.id, + (e) => e.davObjectId == item.id, ), typedResults: items, ), - if (calendarSourcesRefs) + if (pendingOpsRefs) await $_getPrefetchedData< - DavCollection, - $DavCollectionsTable, - CalendarSource + DavObject, + $DavObjectsTable, + PendingOp >( currentTable: table, - referencedTable: $$DavCollectionsTableReferences - ._calendarSourcesRefsTable(db), + referencedTable: $$DavObjectsTableReferences + ._pendingOpsRefsTable(db), managerFromTypedResult: (p0) => - $$DavCollectionsTableReferences( + $$DavObjectsTableReferences( db, table, p0, - ).calendarSourcesRefs, + ).pendingOpsRefs, referencedItemsForCurrentItem: (item, referencedItems) => referencedItems.where( - (e) => e.davCollectionId == item.id, + (e) => e.davObjectId == item.id, ), typedResults: items, ), if (calendarEventsRefs) await $_getPrefetchedData< - DavCollection, - $DavCollectionsTable, + DavObject, + $DavObjectsTable, CalendarEvent >( currentTable: table, - referencedTable: $$DavCollectionsTableReferences + referencedTable: $$DavObjectsTableReferences ._calendarEventsRefsTable(db), managerFromTypedResult: (p0) => - $$DavCollectionsTableReferences( + $$DavObjectsTableReferences( db, table, p0, ).calendarEventsRefs, referencedItemsForCurrentItem: (item, referencedItems) => referencedItems.where( - (e) => e.davCollectionId == item.id, - ), - typedResults: items, - ), - if (syncCursorsRefs) - await $_getPrefetchedData< - DavCollection, - $DavCollectionsTable, - SyncCursor - >( - currentTable: table, - referencedTable: $$DavCollectionsTableReferences - ._syncCursorsRefsTable(db), - managerFromTypedResult: (p0) => - $$DavCollectionsTableReferences( - db, - table, - p0, - ).syncCursorsRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.davCollectionId == item.id, + (e) => e.davObjectId == item.id, ), typedResults: items, ), @@ -26868,174 +30436,89 @@ class $$DavCollectionsTableTableManager ); } -typedef $$DavCollectionsTableProcessedTableManager = +typedef $$DavObjectsTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $DavCollectionsTable, - DavCollection, - $$DavCollectionsTableFilterComposer, - $$DavCollectionsTableOrderingComposer, - $$DavCollectionsTableAnnotationComposer, - $$DavCollectionsTableCreateCompanionBuilder, - $$DavCollectionsTableUpdateCompanionBuilder, - (DavCollection, $$DavCollectionsTableReferences), - DavCollection, + $DavObjectsTable, + DavObject, + $$DavObjectsTableFilterComposer, + $$DavObjectsTableOrderingComposer, + $$DavObjectsTableAnnotationComposer, + $$DavObjectsTableCreateCompanionBuilder, + $$DavObjectsTableUpdateCompanionBuilder, + (DavObject, $$DavObjectsTableReferences), + DavObject, PrefetchHooks Function({ bool accountId, - bool davObjectsRefs, + bool collectionId, + bool davObjectComponentsRefs, bool davConflictSnapshotsRefs, - bool taskListsRefs, bool tasksRefs, - bool calendarSourcesRefs, + bool pendingOpsRefs, bool calendarEventsRefs, - bool syncCursorsRefs, }) >; -typedef $$DavObjectsTableCreateCompanionBuilder = - DavObjectsCompanion Function({ +typedef $$DavObjectComponentsTableCreateCompanionBuilder = + DavObjectComponentsCompanion Function({ required String id, - required String accountId, - required String collectionId, - required String hrefKey, - required String requestUri, - Value etag, - Value contentType, - Value dominantComponentType, - Value componentMask, - Value primaryUid, - required String rawIcsBody, - required String rawBodyHash, - Value semanticHash, - Value serverDeleted, - Value baselineGeneration, - required String firstSeenAtUtc, - required String lastFetchedAtUtc, - required String lastChangedAtUtc, - Value lastParseStatus, - Value lastParseErrorCode, - Value parserVersion, + required String davObjectId, + required String componentType, + required String uid, + Value recurrenceIdKey, + Value sequence, + Value dtstampUtc, + Value lastModifiedUtc, + required String semanticHash, + Value parserProfileVersion, Value rowid, }); -typedef $$DavObjectsTableUpdateCompanionBuilder = - DavObjectsCompanion Function({ +typedef $$DavObjectComponentsTableUpdateCompanionBuilder = + DavObjectComponentsCompanion Function({ Value id, - Value accountId, - Value collectionId, - Value hrefKey, - Value requestUri, - Value etag, - Value contentType, - Value dominantComponentType, - Value componentMask, - Value primaryUid, - Value rawIcsBody, - Value rawBodyHash, - Value semanticHash, - Value serverDeleted, - Value baselineGeneration, - Value firstSeenAtUtc, - Value lastFetchedAtUtc, - Value lastChangedAtUtc, - Value lastParseStatus, - Value lastParseErrorCode, - Value parserVersion, + Value davObjectId, + Value componentType, + Value uid, + Value recurrenceIdKey, + Value sequence, + Value dtstampUtc, + Value lastModifiedUtc, + Value semanticHash, + Value parserProfileVersion, Value rowid, - }); - -final class $$DavObjectsTableReferences - extends BaseReferences<_$AppDatabase, $DavObjectsTable, DavObject> { - $$DavObjectsTableReferences(super.$_db, super.$_table, super.$_typedResult); - - static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.davObjects.accountId, db.accounts.id), - ); - - $$AccountsTableProcessedTableManager get accountId { - final $_column = $_itemColumn('account_id')!; - - final manager = $$AccountsTableTableManager( - $_db, - $_db.accounts, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } - - static $DavCollectionsTable _collectionIdTable(_$AppDatabase db) => - db.davCollections.createAlias( - $_aliasNameGenerator(db.davObjects.collectionId, db.davCollections.id), - ); - - $$DavCollectionsTableProcessedTableManager get collectionId { - final $_column = $_itemColumn('collection_id')!; - - final manager = $$DavCollectionsTableTableManager( - $_db, - $_db.davCollections, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_collectionIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } - - static MultiTypedResultKey< - $DavObjectComponentsTable, - List - > - _davObjectComponentsRefsTable(_$AppDatabase db) => - MultiTypedResultKey.fromTable( - db.davObjectComponents, - aliasName: $_aliasNameGenerator( - db.davObjects.id, - db.davObjectComponents.davObjectId, - ), - ); - - $$DavObjectComponentsTableProcessedTableManager get davObjectComponentsRefs { - final manager = $$DavObjectComponentsTableTableManager( - $_db, - $_db.davObjectComponents, - ).filter((f) => f.davObjectId.id.sqlEquals($_itemColumn('id')!)); + }); - final cache = $_typedResult.readTableOrNull( - _davObjectComponentsRefsTable($_db), - ); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); - } +final class $$DavObjectComponentsTableReferences + extends + BaseReferences< + _$AppDatabase, + $DavObjectComponentsTable, + DavObjectComponent + > { + $$DavObjectComponentsTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); - static MultiTypedResultKey< - $DavConflictSnapshotsTable, - List - > - _davConflictSnapshotsRefsTable(_$AppDatabase db) => - MultiTypedResultKey.fromTable( - db.davConflictSnapshots, - aliasName: $_aliasNameGenerator( + static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => + db.davObjects.createAlias( + $_aliasNameGenerator( + db.davObjectComponents.davObjectId, db.davObjects.id, - db.davConflictSnapshots.davObjectId, ), ); - $$DavConflictSnapshotsTableProcessedTableManager - get davConflictSnapshotsRefs { - final manager = $$DavConflictSnapshotsTableTableManager( - $_db, - $_db.davConflictSnapshots, - ).filter((f) => f.davObjectId.id.sqlEquals($_itemColumn('id')!)); + $$DavObjectsTableProcessedTableManager get davObjectId { + final $_column = $_itemColumn('dav_object_id')!; - final cache = $_typedResult.readTableOrNull( - _davConflictSnapshotsRefsTable($_db), - ); + final manager = $$DavObjectsTableTableManager( + $_db, + $_db.davObjects, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_davObjectIdTable($_db)); + if (item == null) return manager; return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), + manager.$state.copyWith(prefetchedData: [item]), ); } @@ -27043,14 +30526,17 @@ final class $$DavObjectsTableReferences _$AppDatabase db, ) => MultiTypedResultKey.fromTable( db.tasks, - aliasName: $_aliasNameGenerator(db.davObjects.id, db.tasks.davObjectId), + aliasName: $_aliasNameGenerator( + db.davObjectComponents.id, + db.tasks.davComponentId, + ), ); $$TasksTableProcessedTableManager get tasksRefs { final manager = $$TasksTableTableManager( $_db, $_db.tasks, - ).filter((f) => f.davObjectId.id.sqlEquals($_itemColumn('id')!)); + ).filter((f) => f.davComponentId.id.sqlEquals($_itemColumn('id')!)); final cache = $_typedResult.readTableOrNull(_tasksRefsTable($_db)); return ProcessedTableManager( @@ -27058,33 +30544,12 @@ final class $$DavObjectsTableReferences ); } - static MultiTypedResultKey<$PendingOpsTable, List> - _pendingOpsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.pendingOps, - aliasName: $_aliasNameGenerator( - db.davObjects.id, - db.pendingOps.davObjectId, - ), - ); - - $$PendingOpsTableProcessedTableManager get pendingOpsRefs { - final manager = $$PendingOpsTableTableManager( - $_db, - $_db.pendingOps, - ).filter((f) => f.davObjectId.id.sqlEquals($_itemColumn('id')!)); - - final cache = $_typedResult.readTableOrNull(_pendingOpsRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); - } - static MultiTypedResultKey<$CalendarEventsTable, List> _calendarEventsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.calendarEvents, aliasName: $_aliasNameGenerator( - db.davObjects.id, - db.calendarEvents.davObjectId, + db.davObjectComponents.id, + db.calendarEvents.davComponentId, ), ); @@ -27092,7 +30557,7 @@ final class $$DavObjectsTableReferences final manager = $$CalendarEventsTableTableManager( $_db, $_db.calendarEvents, - ).filter((f) => f.davObjectId.id.sqlEquals($_itemColumn('id')!)); + ).filter((f) => f.davComponentId.id.sqlEquals($_itemColumn('id')!)); final cache = $_typedResult.readTableOrNull(_calendarEventsRefsTable($_db)); return ProcessedTableManager( @@ -27101,9 +30566,9 @@ final class $$DavObjectsTableReferences } } -class $$DavObjectsTableFilterComposer - extends Composer<_$AppDatabase, $DavObjectsTable> { - $$DavObjectsTableFilterComposer({ +class $$DavObjectComponentsTableFilterComposer + extends Composer<_$AppDatabase, $DavObjectComponentsTable> { + $$DavObjectComponentsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -27115,48 +30580,33 @@ class $$DavObjectsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get hrefKey => $composableBuilder( - column: $table.hrefKey, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get requestUri => $composableBuilder( - column: $table.requestUri, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get etag => $composableBuilder( - column: $table.etag, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get contentType => $composableBuilder( - column: $table.contentType, + ColumnFilters get componentType => $composableBuilder( + column: $table.componentType, builder: (column) => ColumnFilters(column), ); - ColumnFilters get dominantComponentType => $composableBuilder( - column: $table.dominantComponentType, + ColumnFilters get uid => $composableBuilder( + column: $table.uid, builder: (column) => ColumnFilters(column), ); - ColumnFilters get componentMask => $composableBuilder( - column: $table.componentMask, + ColumnFilters get recurrenceIdKey => $composableBuilder( + column: $table.recurrenceIdKey, builder: (column) => ColumnFilters(column), ); - ColumnFilters get primaryUid => $composableBuilder( - column: $table.primaryUid, + ColumnFilters get sequence => $composableBuilder( + column: $table.sequence, builder: (column) => ColumnFilters(column), ); - ColumnFilters get rawIcsBody => $composableBuilder( - column: $table.rawIcsBody, + ColumnFilters get dtstampUtc => $composableBuilder( + column: $table.dtstampUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get rawBodyHash => $composableBuilder( - column: $table.rawBodyHash, + ColumnFilters get lastModifiedUtc => $composableBuilder( + column: $table.lastModifiedUtc, builder: (column) => ColumnFilters(column), ); @@ -27165,83 +30615,25 @@ class $$DavObjectsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get serverDeleted => $composableBuilder( - column: $table.serverDeleted, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get baselineGeneration => $composableBuilder( - column: $table.baselineGeneration, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get firstSeenAtUtc => $composableBuilder( - column: $table.firstSeenAtUtc, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get lastFetchedAtUtc => $composableBuilder( - column: $table.lastFetchedAtUtc, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get lastChangedAtUtc => $composableBuilder( - column: $table.lastChangedAtUtc, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get lastParseStatus => $composableBuilder( - column: $table.lastParseStatus, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get lastParseErrorCode => $composableBuilder( - column: $table.lastParseErrorCode, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get parserVersion => $composableBuilder( - column: $table.parserVersion, + ColumnFilters get parserProfileVersion => $composableBuilder( + column: $table.parserProfileVersion, builder: (column) => ColumnFilters(column), ); - $$AccountsTableFilterComposer get accountId { - final $$AccountsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableFilterComposer( - $db: $db, - $table: $db.accounts, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavCollectionsTableFilterComposer get collectionId { - final $$DavCollectionsTableFilterComposer composer = $composerBuilder( + $$DavObjectsTableFilterComposer get davObjectId { + final $$DavObjectsTableFilterComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.collectionId, - referencedTable: $db.davCollections, + getCurrentColumn: (t) => t.davObjectId, + referencedTable: $db.davObjects, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableFilterComposer( + }) => $$DavObjectsTableFilterComposer( $db: $db, - $table: $db.davCollections, + $table: $db.davObjects, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -27251,56 +30643,6 @@ class $$DavObjectsTableFilterComposer return composer; } - Expression davObjectComponentsRefs( - Expression Function($$DavObjectComponentsTableFilterComposer f) f, - ) { - final $$DavObjectComponentsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.davObjectComponents, - getReferencedColumn: (t) => t.davObjectId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavObjectComponentsTableFilterComposer( - $db: $db, - $table: $db.davObjectComponents, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } - - Expression davConflictSnapshotsRefs( - Expression Function($$DavConflictSnapshotsTableFilterComposer f) f, - ) { - final $$DavConflictSnapshotsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.davConflictSnapshots, - getReferencedColumn: (t) => t.davObjectId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavConflictSnapshotsTableFilterComposer( - $db: $db, - $table: $db.davConflictSnapshots, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } - Expression tasksRefs( Expression Function($$TasksTableFilterComposer f) f, ) { @@ -27308,7 +30650,7 @@ class $$DavObjectsTableFilterComposer composer: this, getCurrentColumn: (t) => t.id, referencedTable: $db.tasks, - getReferencedColumn: (t) => t.davObjectId, + getReferencedColumn: (t) => t.davComponentId, builder: ( joinBuilder, { @@ -27326,31 +30668,6 @@ class $$DavObjectsTableFilterComposer return f(composer); } - Expression pendingOpsRefs( - Expression Function($$PendingOpsTableFilterComposer f) f, - ) { - final $$PendingOpsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.pendingOps, - getReferencedColumn: (t) => t.davObjectId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$PendingOpsTableFilterComposer( - $db: $db, - $table: $db.pendingOps, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } - Expression calendarEventsRefs( Expression Function($$CalendarEventsTableFilterComposer f) f, ) { @@ -27358,7 +30675,7 @@ class $$DavObjectsTableFilterComposer composer: this, getCurrentColumn: (t) => t.id, referencedTable: $db.calendarEvents, - getReferencedColumn: (t) => t.davObjectId, + getReferencedColumn: (t) => t.davComponentId, builder: ( joinBuilder, { @@ -27377,9 +30694,9 @@ class $$DavObjectsTableFilterComposer } } -class $$DavObjectsTableOrderingComposer - extends Composer<_$AppDatabase, $DavObjectsTable> { - $$DavObjectsTableOrderingComposer({ +class $$DavObjectComponentsTableOrderingComposer + extends Composer<_$AppDatabase, $DavObjectComponentsTable> { + $$DavObjectComponentsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -27391,255 +30708,60 @@ class $$DavObjectsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get hrefKey => $composableBuilder( - column: $table.hrefKey, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get requestUri => $composableBuilder( - column: $table.requestUri, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get etag => $composableBuilder( - column: $table.etag, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get contentType => $composableBuilder( - column: $table.contentType, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get dominantComponentType => $composableBuilder( - column: $table.dominantComponentType, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get componentMask => $composableBuilder( - column: $table.componentMask, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get primaryUid => $composableBuilder( - column: $table.primaryUid, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get rawIcsBody => $composableBuilder( - column: $table.rawIcsBody, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get rawBodyHash => $composableBuilder( - column: $table.rawBodyHash, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get semanticHash => $composableBuilder( - column: $table.semanticHash, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get serverDeleted => $composableBuilder( - column: $table.serverDeleted, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get baselineGeneration => $composableBuilder( - column: $table.baselineGeneration, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get firstSeenAtUtc => $composableBuilder( - column: $table.firstSeenAtUtc, + ColumnOrderings get componentType => $composableBuilder( + column: $table.componentType, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get lastFetchedAtUtc => $composableBuilder( - column: $table.lastFetchedAtUtc, + ColumnOrderings get uid => $composableBuilder( + column: $table.uid, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get lastChangedAtUtc => $composableBuilder( - column: $table.lastChangedAtUtc, + ColumnOrderings get recurrenceIdKey => $composableBuilder( + column: $table.recurrenceIdKey, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get lastParseStatus => $composableBuilder( - column: $table.lastParseStatus, + ColumnOrderings get sequence => $composableBuilder( + column: $table.sequence, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get lastParseErrorCode => $composableBuilder( - column: $table.lastParseErrorCode, + ColumnOrderings get dtstampUtc => $composableBuilder( + column: $table.dtstampUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get parserVersion => $composableBuilder( - column: $table.parserVersion, + ColumnOrderings get lastModifiedUtc => $composableBuilder( + column: $table.lastModifiedUtc, builder: (column) => ColumnOrderings(column), ); - $$AccountsTableOrderingComposer get accountId { - final $$AccountsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableOrderingComposer( - $db: $db, - $table: $db.accounts, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavCollectionsTableOrderingComposer get collectionId { - final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.collectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableOrderingComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } -} - -class $$DavObjectsTableAnnotationComposer - extends Composer<_$AppDatabase, $DavObjectsTable> { - $$DavObjectsTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get hrefKey => - $composableBuilder(column: $table.hrefKey, builder: (column) => column); - - GeneratedColumn get requestUri => $composableBuilder( - column: $table.requestUri, - builder: (column) => column, - ); - - GeneratedColumn get etag => - $composableBuilder(column: $table.etag, builder: (column) => column); - - GeneratedColumn get contentType => $composableBuilder( - column: $table.contentType, - builder: (column) => column, - ); - - GeneratedColumn get dominantComponentType => $composableBuilder( - column: $table.dominantComponentType, - builder: (column) => column, - ); - - GeneratedColumn get componentMask => $composableBuilder( - column: $table.componentMask, - builder: (column) => column, - ); - - GeneratedColumn get primaryUid => $composableBuilder( - column: $table.primaryUid, - builder: (column) => column, - ); - - GeneratedColumn get rawIcsBody => $composableBuilder( - column: $table.rawIcsBody, - builder: (column) => column, - ); - - GeneratedColumn get rawBodyHash => $composableBuilder( - column: $table.rawBodyHash, - builder: (column) => column, - ); - - GeneratedColumn get semanticHash => $composableBuilder( - column: $table.semanticHash, - builder: (column) => column, - ); - - GeneratedColumn get serverDeleted => $composableBuilder( - column: $table.serverDeleted, - builder: (column) => column, - ); - - GeneratedColumn get baselineGeneration => $composableBuilder( - column: $table.baselineGeneration, - builder: (column) => column, - ); - - GeneratedColumn get firstSeenAtUtc => $composableBuilder( - column: $table.firstSeenAtUtc, - builder: (column) => column, - ); - - GeneratedColumn get lastFetchedAtUtc => $composableBuilder( - column: $table.lastFetchedAtUtc, - builder: (column) => column, - ); - - GeneratedColumn get lastChangedAtUtc => $composableBuilder( - column: $table.lastChangedAtUtc, - builder: (column) => column, - ); - - GeneratedColumn get lastParseStatus => $composableBuilder( - column: $table.lastParseStatus, - builder: (column) => column, - ); - - GeneratedColumn get lastParseErrorCode => $composableBuilder( - column: $table.lastParseErrorCode, - builder: (column) => column, + ColumnOrderings get semanticHash => $composableBuilder( + column: $table.semanticHash, + builder: (column) => ColumnOrderings(column), ); - GeneratedColumn get parserVersion => $composableBuilder( - column: $table.parserVersion, - builder: (column) => column, + ColumnOrderings get parserProfileVersion => $composableBuilder( + column: $table.parserProfileVersion, + builder: (column) => ColumnOrderings(column), ); - $$AccountsTableAnnotationComposer get accountId { - final $$AccountsTableAnnotationComposer composer = $composerBuilder( + $$DavObjectsTableOrderingComposer get davObjectId { + final $$DavObjectsTableOrderingComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, + getCurrentColumn: (t) => t.davObjectId, + referencedTable: $db.davObjects, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableAnnotationComposer( + }) => $$DavObjectsTableOrderingComposer( $db: $db, - $table: $db.accounts, + $table: $db.davObjects, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -27648,21 +30770,70 @@ class $$DavObjectsTableAnnotationComposer ); return composer; } +} - $$DavCollectionsTableAnnotationComposer get collectionId { - final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( +class $$DavObjectComponentsTableAnnotationComposer + extends Composer<_$AppDatabase, $DavObjectComponentsTable> { + $$DavObjectComponentsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get componentType => $composableBuilder( + column: $table.componentType, + builder: (column) => column, + ); + + GeneratedColumn get uid => + $composableBuilder(column: $table.uid, builder: (column) => column); + + GeneratedColumn get recurrenceIdKey => $composableBuilder( + column: $table.recurrenceIdKey, + builder: (column) => column, + ); + + GeneratedColumn get sequence => + $composableBuilder(column: $table.sequence, builder: (column) => column); + + GeneratedColumn get dtstampUtc => $composableBuilder( + column: $table.dtstampUtc, + builder: (column) => column, + ); + + GeneratedColumn get lastModifiedUtc => $composableBuilder( + column: $table.lastModifiedUtc, + builder: (column) => column, + ); + + GeneratedColumn get semanticHash => $composableBuilder( + column: $table.semanticHash, + builder: (column) => column, + ); + + GeneratedColumn get parserProfileVersion => $composableBuilder( + column: $table.parserProfileVersion, + builder: (column) => column, + ); + + $$DavObjectsTableAnnotationComposer get davObjectId { + final $$DavObjectsTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.collectionId, - referencedTable: $db.davCollections, + getCurrentColumn: (t) => t.davObjectId, + referencedTable: $db.davObjects, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableAnnotationComposer( + }) => $$DavObjectsTableAnnotationComposer( $db: $db, - $table: $db.davCollections, + $table: $db.davObjects, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -27672,58 +30843,6 @@ class $$DavObjectsTableAnnotationComposer return composer; } - Expression davObjectComponentsRefs( - Expression Function($$DavObjectComponentsTableAnnotationComposer a) f, - ) { - final $$DavObjectComponentsTableAnnotationComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.davObjectComponents, - getReferencedColumn: (t) => t.davObjectId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavObjectComponentsTableAnnotationComposer( - $db: $db, - $table: $db.davObjectComponents, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } - - Expression davConflictSnapshotsRefs( - Expression Function($$DavConflictSnapshotsTableAnnotationComposer a) f, - ) { - final $$DavConflictSnapshotsTableAnnotationComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.davConflictSnapshots, - getReferencedColumn: (t) => t.davObjectId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavConflictSnapshotsTableAnnotationComposer( - $db: $db, - $table: $db.davConflictSnapshots, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } - Expression tasksRefs( Expression Function($$TasksTableAnnotationComposer a) f, ) { @@ -27731,7 +30850,7 @@ class $$DavObjectsTableAnnotationComposer composer: this, getCurrentColumn: (t) => t.id, referencedTable: $db.tasks, - getReferencedColumn: (t) => t.davObjectId, + getReferencedColumn: (t) => t.davComponentId, builder: ( joinBuilder, { @@ -27749,31 +30868,6 @@ class $$DavObjectsTableAnnotationComposer return f(composer); } - Expression pendingOpsRefs( - Expression Function($$PendingOpsTableAnnotationComposer a) f, - ) { - final $$PendingOpsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.pendingOps, - getReferencedColumn: (t) => t.davObjectId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$PendingOpsTableAnnotationComposer( - $db: $db, - $table: $db.pendingOps, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } - Expression calendarEventsRefs( Expression Function($$CalendarEventsTableAnnotationComposer a) f, ) { @@ -27781,7 +30875,7 @@ class $$DavObjectsTableAnnotationComposer composer: this, getCurrentColumn: (t) => t.id, referencedTable: $db.calendarEvents, - getReferencedColumn: (t) => t.davObjectId, + getReferencedColumn: (t) => t.davComponentId, builder: ( joinBuilder, { @@ -27800,161 +30894,114 @@ class $$DavObjectsTableAnnotationComposer } } -class $$DavObjectsTableTableManager +class $$DavObjectComponentsTableTableManager extends RootTableManager< _$AppDatabase, - $DavObjectsTable, - DavObject, - $$DavObjectsTableFilterComposer, - $$DavObjectsTableOrderingComposer, - $$DavObjectsTableAnnotationComposer, - $$DavObjectsTableCreateCompanionBuilder, - $$DavObjectsTableUpdateCompanionBuilder, - (DavObject, $$DavObjectsTableReferences), - DavObject, + $DavObjectComponentsTable, + DavObjectComponent, + $$DavObjectComponentsTableFilterComposer, + $$DavObjectComponentsTableOrderingComposer, + $$DavObjectComponentsTableAnnotationComposer, + $$DavObjectComponentsTableCreateCompanionBuilder, + $$DavObjectComponentsTableUpdateCompanionBuilder, + (DavObjectComponent, $$DavObjectComponentsTableReferences), + DavObjectComponent, PrefetchHooks Function({ - bool accountId, - bool collectionId, - bool davObjectComponentsRefs, - bool davConflictSnapshotsRefs, + bool davObjectId, bool tasksRefs, - bool pendingOpsRefs, bool calendarEventsRefs, }) > { - $$DavObjectsTableTableManager(_$AppDatabase db, $DavObjectsTable table) - : super( + $$DavObjectComponentsTableTableManager( + _$AppDatabase db, + $DavObjectComponentsTable table, + ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$DavObjectsTableFilterComposer($db: db, $table: table), + $$DavObjectComponentsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$DavObjectsTableOrderingComposer($db: db, $table: table), + $$DavObjectComponentsTableOrderingComposer( + $db: db, + $table: table, + ), createComputedFieldComposer: () => - $$DavObjectsTableAnnotationComposer($db: db, $table: table), + $$DavObjectComponentsTableAnnotationComposer( + $db: db, + $table: table, + ), updateCompanionCallback: ({ Value id = const Value.absent(), - Value accountId = const Value.absent(), - Value collectionId = const Value.absent(), - Value hrefKey = const Value.absent(), - Value requestUri = const Value.absent(), - Value etag = const Value.absent(), - Value contentType = const Value.absent(), - Value dominantComponentType = const Value.absent(), - Value componentMask = const Value.absent(), - Value primaryUid = const Value.absent(), - Value rawIcsBody = const Value.absent(), - Value rawBodyHash = const Value.absent(), - Value semanticHash = const Value.absent(), - Value serverDeleted = const Value.absent(), - Value baselineGeneration = const Value.absent(), - Value firstSeenAtUtc = const Value.absent(), - Value lastFetchedAtUtc = const Value.absent(), - Value lastChangedAtUtc = const Value.absent(), - Value lastParseStatus = const Value.absent(), - Value lastParseErrorCode = const Value.absent(), - Value parserVersion = const Value.absent(), + Value davObjectId = const Value.absent(), + Value componentType = const Value.absent(), + Value uid = const Value.absent(), + Value recurrenceIdKey = const Value.absent(), + Value sequence = const Value.absent(), + Value dtstampUtc = const Value.absent(), + Value lastModifiedUtc = const Value.absent(), + Value semanticHash = const Value.absent(), + Value parserProfileVersion = const Value.absent(), Value rowid = const Value.absent(), - }) => DavObjectsCompanion( + }) => DavObjectComponentsCompanion( id: id, - accountId: accountId, - collectionId: collectionId, - hrefKey: hrefKey, - requestUri: requestUri, - etag: etag, - contentType: contentType, - dominantComponentType: dominantComponentType, - componentMask: componentMask, - primaryUid: primaryUid, - rawIcsBody: rawIcsBody, - rawBodyHash: rawBodyHash, + davObjectId: davObjectId, + componentType: componentType, + uid: uid, + recurrenceIdKey: recurrenceIdKey, + sequence: sequence, + dtstampUtc: dtstampUtc, + lastModifiedUtc: lastModifiedUtc, semanticHash: semanticHash, - serverDeleted: serverDeleted, - baselineGeneration: baselineGeneration, - firstSeenAtUtc: firstSeenAtUtc, - lastFetchedAtUtc: lastFetchedAtUtc, - lastChangedAtUtc: lastChangedAtUtc, - lastParseStatus: lastParseStatus, - lastParseErrorCode: lastParseErrorCode, - parserVersion: parserVersion, + parserProfileVersion: parserProfileVersion, rowid: rowid, ), createCompanionCallback: ({ required String id, - required String accountId, - required String collectionId, - required String hrefKey, - required String requestUri, - Value etag = const Value.absent(), - Value contentType = const Value.absent(), - Value dominantComponentType = const Value.absent(), - Value componentMask = const Value.absent(), - Value primaryUid = const Value.absent(), - required String rawIcsBody, - required String rawBodyHash, - Value semanticHash = const Value.absent(), - Value serverDeleted = const Value.absent(), - Value baselineGeneration = const Value.absent(), - required String firstSeenAtUtc, - required String lastFetchedAtUtc, - required String lastChangedAtUtc, - Value lastParseStatus = const Value.absent(), - Value lastParseErrorCode = const Value.absent(), - Value parserVersion = const Value.absent(), + required String davObjectId, + required String componentType, + required String uid, + Value recurrenceIdKey = const Value.absent(), + Value sequence = const Value.absent(), + Value dtstampUtc = const Value.absent(), + Value lastModifiedUtc = const Value.absent(), + required String semanticHash, + Value parserProfileVersion = const Value.absent(), Value rowid = const Value.absent(), - }) => DavObjectsCompanion.insert( + }) => DavObjectComponentsCompanion.insert( id: id, - accountId: accountId, - collectionId: collectionId, - hrefKey: hrefKey, - requestUri: requestUri, - etag: etag, - contentType: contentType, - dominantComponentType: dominantComponentType, - componentMask: componentMask, - primaryUid: primaryUid, - rawIcsBody: rawIcsBody, - rawBodyHash: rawBodyHash, + davObjectId: davObjectId, + componentType: componentType, + uid: uid, + recurrenceIdKey: recurrenceIdKey, + sequence: sequence, + dtstampUtc: dtstampUtc, + lastModifiedUtc: lastModifiedUtc, semanticHash: semanticHash, - serverDeleted: serverDeleted, - baselineGeneration: baselineGeneration, - firstSeenAtUtc: firstSeenAtUtc, - lastFetchedAtUtc: lastFetchedAtUtc, - lastChangedAtUtc: lastChangedAtUtc, - lastParseStatus: lastParseStatus, - lastParseErrorCode: lastParseErrorCode, - parserVersion: parserVersion, + parserProfileVersion: parserProfileVersion, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( e.readTable(table), - $$DavObjectsTableReferences(db, table, e), + $$DavObjectComponentsTableReferences(db, table, e), ), ) .toList(), prefetchHooksCallback: ({ - accountId = false, - collectionId = false, - davObjectComponentsRefs = false, - davConflictSnapshotsRefs = false, + davObjectId = false, tasksRefs = false, - pendingOpsRefs = false, calendarEventsRefs = false, }) { return PrefetchHooks( db: db, explicitlyWatchedTables: [ - if (davObjectComponentsRefs) db.davObjectComponents, - if (davConflictSnapshotsRefs) db.davConflictSnapshots, if (tasksRefs) db.tasks, - if (pendingOpsRefs) db.pendingOps, if (calendarEventsRefs) db.calendarEvents, ], addJoins: @@ -27973,30 +31020,17 @@ class $$DavObjectsTableTableManager dynamic > >(state) { - if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: $$DavObjectsTableReferences - ._accountIdTable(db), - referencedColumn: - $$DavObjectsTableReferences - ._accountIdTable(db) - .id, - ) - as T; - } - if (collectionId) { + if (davObjectId) { state = state.withJoin( currentTable: table, - currentColumn: table.collectionId, - referencedTable: $$DavObjectsTableReferences - ._collectionIdTable(db), + currentColumn: table.davObjectId, + referencedTable: + $$DavObjectComponentsTableReferences + ._davObjectIdTable(db), referencedColumn: - $$DavObjectsTableReferences - ._collectionIdTable(db) + $$DavObjectComponentsTableReferences + ._davObjectIdTable(db) .id, ) as T; @@ -28006,108 +31040,45 @@ class $$DavObjectsTableTableManager }, getPrefetchedDataCallback: (items) async { return [ - if (davObjectComponentsRefs) - await $_getPrefetchedData< - DavObject, - $DavObjectsTable, - DavObjectComponent - >( - currentTable: table, - referencedTable: $$DavObjectsTableReferences - ._davObjectComponentsRefsTable(db), - managerFromTypedResult: (p0) => - $$DavObjectsTableReferences( - db, - table, - p0, - ).davObjectComponentsRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.davObjectId == item.id, - ), - typedResults: items, - ), - if (davConflictSnapshotsRefs) - await $_getPrefetchedData< - DavObject, - $DavObjectsTable, - DavConflictSnapshot - >( - currentTable: table, - referencedTable: $$DavObjectsTableReferences - ._davConflictSnapshotsRefsTable(db), - managerFromTypedResult: (p0) => - $$DavObjectsTableReferences( - db, - table, - p0, - ).davConflictSnapshotsRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.davObjectId == item.id, - ), - typedResults: items, - ), if (tasksRefs) await $_getPrefetchedData< - DavObject, - $DavObjectsTable, + DavObjectComponent, + $DavObjectComponentsTable, Task >( currentTable: table, - referencedTable: $$DavObjectsTableReferences + referencedTable: $$DavObjectComponentsTableReferences ._tasksRefsTable(db), managerFromTypedResult: (p0) => - $$DavObjectsTableReferences( + $$DavObjectComponentsTableReferences( db, table, p0, ).tasksRefs, referencedItemsForCurrentItem: (item, referencedItems) => referencedItems.where( - (e) => e.davObjectId == item.id, - ), - typedResults: items, - ), - if (pendingOpsRefs) - await $_getPrefetchedData< - DavObject, - $DavObjectsTable, - PendingOp - >( - currentTable: table, - referencedTable: $$DavObjectsTableReferences - ._pendingOpsRefsTable(db), - managerFromTypedResult: (p0) => - $$DavObjectsTableReferences( - db, - table, - p0, - ).pendingOpsRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.davObjectId == item.id, + (e) => e.davComponentId == item.id, ), typedResults: items, ), if (calendarEventsRefs) await $_getPrefetchedData< - DavObject, - $DavObjectsTable, + DavObjectComponent, + $DavObjectComponentsTable, CalendarEvent >( currentTable: table, - referencedTable: $$DavObjectsTableReferences + referencedTable: $$DavObjectComponentsTableReferences ._calendarEventsRefsTable(db), managerFromTypedResult: (p0) => - $$DavObjectsTableReferences( + $$DavObjectComponentsTableReferences( db, table, p0, ).calendarEventsRefs, referencedItemsForCurrentItem: (item, referencedItems) => referencedItems.where( - (e) => e.davObjectId == item.id, + (e) => e.davComponentId == item.id, ), typedResults: items, ), @@ -28119,81 +31090,124 @@ class $$DavObjectsTableTableManager ); } -typedef $$DavObjectsTableProcessedTableManager = +typedef $$DavObjectComponentsTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $DavObjectsTable, - DavObject, - $$DavObjectsTableFilterComposer, - $$DavObjectsTableOrderingComposer, - $$DavObjectsTableAnnotationComposer, - $$DavObjectsTableCreateCompanionBuilder, - $$DavObjectsTableUpdateCompanionBuilder, - (DavObject, $$DavObjectsTableReferences), - DavObject, + $DavObjectComponentsTable, + DavObjectComponent, + $$DavObjectComponentsTableFilterComposer, + $$DavObjectComponentsTableOrderingComposer, + $$DavObjectComponentsTableAnnotationComposer, + $$DavObjectComponentsTableCreateCompanionBuilder, + $$DavObjectComponentsTableUpdateCompanionBuilder, + (DavObjectComponent, $$DavObjectComponentsTableReferences), + DavObjectComponent, PrefetchHooks Function({ - bool accountId, - bool collectionId, - bool davObjectComponentsRefs, - bool davConflictSnapshotsRefs, + bool davObjectId, bool tasksRefs, - bool pendingOpsRefs, bool calendarEventsRefs, }) >; -typedef $$DavObjectComponentsTableCreateCompanionBuilder = - DavObjectComponentsCompanion Function({ +typedef $$DavConflictSnapshotsTableCreateCompanionBuilder = + DavConflictSnapshotsCompanion Function({ required String id, - required String davObjectId, - required String componentType, - required String uid, - Value recurrenceIdKey, - Value sequence, - Value dtstampUtc, - Value lastModifiedUtc, - required String semanticHash, - Value parserProfileVersion, + required String accountId, + Value davCollectionId, + Value davObjectId, + Value baselineEtag, + required String baselineRawIcs, + required String localCandidateRawIcs, + Value remoteEtag, + required String remoteRawIcs, + required String conflictCode, + required String createdAtUtc, + Value resolvedAtUtc, + Value resolution, Value rowid, }); -typedef $$DavObjectComponentsTableUpdateCompanionBuilder = - DavObjectComponentsCompanion Function({ +typedef $$DavConflictSnapshotsTableUpdateCompanionBuilder = + DavConflictSnapshotsCompanion Function({ Value id, - Value davObjectId, - Value componentType, - Value uid, - Value recurrenceIdKey, - Value sequence, - Value dtstampUtc, - Value lastModifiedUtc, - Value semanticHash, - Value parserProfileVersion, + Value accountId, + Value davCollectionId, + Value davObjectId, + Value baselineEtag, + Value baselineRawIcs, + Value localCandidateRawIcs, + Value remoteEtag, + Value remoteRawIcs, + Value conflictCode, + Value createdAtUtc, + Value resolvedAtUtc, + Value resolution, Value rowid, }); -final class $$DavObjectComponentsTableReferences +final class $$DavConflictSnapshotsTableReferences extends BaseReferences< _$AppDatabase, - $DavObjectComponentsTable, - DavObjectComponent + $DavConflictSnapshotsTable, + DavConflictSnapshot > { - $$DavObjectComponentsTableReferences( + $$DavConflictSnapshotsTableReferences( super.$_db, super.$_table, super.$_typedResult, ); + static $AccountsTable _accountIdTable(_$AppDatabase db) => + db.accounts.createAlias( + $_aliasNameGenerator(db.davConflictSnapshots.accountId, db.accounts.id), + ); + + $$AccountsTableProcessedTableManager get accountId { + final $_column = $_itemColumn('account_id')!; + + final manager = $$AccountsTableTableManager( + $_db, + $_db.accounts, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => + db.davCollections.createAlias( + $_aliasNameGenerator( + db.davConflictSnapshots.davCollectionId, + db.davCollections.id, + ), + ); + + $$DavCollectionsTableProcessedTableManager? get davCollectionId { + final $_column = $_itemColumn('dav_collection_id'); + if ($_column == null) return null; + final manager = $$DavCollectionsTableTableManager( + $_db, + $_db.davCollections, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_davCollectionIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => db.davObjects.createAlias( $_aliasNameGenerator( - db.davObjectComponents.davObjectId, + db.davConflictSnapshots.davObjectId, db.davObjects.id, ), ); - $$DavObjectsTableProcessedTableManager get davObjectId { - final $_column = $_itemColumn('dav_object_id')!; - + $$DavObjectsTableProcessedTableManager? get davObjectId { + final $_column = $_itemColumn('dav_object_id'); + if ($_column == null) return null; final manager = $$DavObjectsTableTableManager( $_db, $_db.davObjects, @@ -28205,53 +31219,30 @@ final class $$DavObjectComponentsTableReferences ); } - static MultiTypedResultKey<$TasksTable, List> _tasksRefsTable( - _$AppDatabase db, - ) => MultiTypedResultKey.fromTable( - db.tasks, + static MultiTypedResultKey<$PendingOpsTable, List> + _pendingOpsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.pendingOps, aliasName: $_aliasNameGenerator( - db.davObjectComponents.id, - db.tasks.davComponentId, + db.davConflictSnapshots.id, + db.pendingOps.conflictSnapshotId, ), ); - $$TasksTableProcessedTableManager get tasksRefs { - final manager = $$TasksTableTableManager( - $_db, - $_db.tasks, - ).filter((f) => f.davComponentId.id.sqlEquals($_itemColumn('id')!)); - - final cache = $_typedResult.readTableOrNull(_tasksRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), + $$PendingOpsTableProcessedTableManager get pendingOpsRefs { + final manager = $$PendingOpsTableTableManager($_db, $_db.pendingOps).filter( + (f) => f.conflictSnapshotId.id.sqlEquals($_itemColumn('id')!), ); - } - - static MultiTypedResultKey<$CalendarEventsTable, List> - _calendarEventsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.calendarEvents, - aliasName: $_aliasNameGenerator( - db.davObjectComponents.id, - db.calendarEvents.davComponentId, - ), - ); - - $$CalendarEventsTableProcessedTableManager get calendarEventsRefs { - final manager = $$CalendarEventsTableTableManager( - $_db, - $_db.calendarEvents, - ).filter((f) => f.davComponentId.id.sqlEquals($_itemColumn('id')!)); - final cache = $_typedResult.readTableOrNull(_calendarEventsRefsTable($_db)); + final cache = $_typedResult.readTableOrNull(_pendingOpsRefsTable($_db)); return ProcessedTableManager( manager.$state.copyWith(prefetchedData: cache), ); } } -class $$DavObjectComponentsTableFilterComposer - extends Composer<_$AppDatabase, $DavObjectComponentsTable> { - $$DavObjectComponentsTableFilterComposer({ +class $$DavConflictSnapshotsTableFilterComposer + extends Composer<_$AppDatabase, $DavConflictSnapshotsTable> { + $$DavConflictSnapshotsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -28263,60 +31254,65 @@ class $$DavObjectComponentsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get componentType => $composableBuilder( - column: $table.componentType, + ColumnFilters get baselineEtag => $composableBuilder( + column: $table.baselineEtag, builder: (column) => ColumnFilters(column), ); - ColumnFilters get uid => $composableBuilder( - column: $table.uid, + ColumnFilters get baselineRawIcs => $composableBuilder( + column: $table.baselineRawIcs, builder: (column) => ColumnFilters(column), ); - ColumnFilters get recurrenceIdKey => $composableBuilder( - column: $table.recurrenceIdKey, + ColumnFilters get localCandidateRawIcs => $composableBuilder( + column: $table.localCandidateRawIcs, builder: (column) => ColumnFilters(column), ); - ColumnFilters get sequence => $composableBuilder( - column: $table.sequence, + ColumnFilters get remoteEtag => $composableBuilder( + column: $table.remoteEtag, builder: (column) => ColumnFilters(column), ); - ColumnFilters get dtstampUtc => $composableBuilder( - column: $table.dtstampUtc, + ColumnFilters get remoteRawIcs => $composableBuilder( + column: $table.remoteRawIcs, builder: (column) => ColumnFilters(column), ); - ColumnFilters get lastModifiedUtc => $composableBuilder( - column: $table.lastModifiedUtc, + ColumnFilters get conflictCode => $composableBuilder( + column: $table.conflictCode, builder: (column) => ColumnFilters(column), ); - ColumnFilters get semanticHash => $composableBuilder( - column: $table.semanticHash, + ColumnFilters get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get parserProfileVersion => $composableBuilder( - column: $table.parserProfileVersion, + ColumnFilters get resolvedAtUtc => $composableBuilder( + column: $table.resolvedAtUtc, builder: (column) => ColumnFilters(column), ); - $$DavObjectsTableFilterComposer get davObjectId { - final $$DavObjectsTableFilterComposer composer = $composerBuilder( + ColumnFilters get resolution => $composableBuilder( + column: $table.resolution, + builder: (column) => ColumnFilters(column), + ); + + $$AccountsTableFilterComposer get accountId { + final $$AccountsTableFilterComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.davObjectId, - referencedTable: $db.davObjects, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableFilterComposer( + }) => $$AccountsTableFilterComposer( $db: $db, - $table: $db.davObjects, + $table: $db.accounts, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -28326,47 +31322,68 @@ class $$DavObjectComponentsTableFilterComposer return composer; } - Expression tasksRefs( - Expression Function($$TasksTableFilterComposer f) f, - ) { - final $$TasksTableFilterComposer composer = $composerBuilder( + $$DavCollectionsTableFilterComposer get davCollectionId { + final $$DavCollectionsTableFilterComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.tasks, - getReferencedColumn: (t) => t.davComponentId, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$TasksTableFilterComposer( + }) => $$DavCollectionsTableFilterComposer( $db: $db, - $table: $db.tasks, + $table: $db.davCollections, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, ), ); - return f(composer); + return composer; } - Expression calendarEventsRefs( - Expression Function($$CalendarEventsTableFilterComposer f) f, + $$DavObjectsTableFilterComposer get davObjectId { + final $$DavObjectsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davObjectId, + referencedTable: $db.davObjects, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectsTableFilterComposer( + $db: $db, + $table: $db.davObjects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + Expression pendingOpsRefs( + Expression Function($$PendingOpsTableFilterComposer f) f, ) { - final $$CalendarEventsTableFilterComposer composer = $composerBuilder( + final $$PendingOpsTableFilterComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarEvents, - getReferencedColumn: (t) => t.davComponentId, + referencedTable: $db.pendingOps, + getReferencedColumn: (t) => t.conflictSnapshotId, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventsTableFilterComposer( + }) => $$PendingOpsTableFilterComposer( $db: $db, - $table: $db.calendarEvents, + $table: $db.pendingOps, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -28377,9 +31394,9 @@ class $$DavObjectComponentsTableFilterComposer } } -class $$DavObjectComponentsTableOrderingComposer - extends Composer<_$AppDatabase, $DavObjectComponentsTable> { - $$DavObjectComponentsTableOrderingComposer({ +class $$DavConflictSnapshotsTableOrderingComposer + extends Composer<_$AppDatabase, $DavConflictSnapshotsTable> { + $$DavConflictSnapshotsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -28391,46 +31408,97 @@ class $$DavObjectComponentsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get componentType => $composableBuilder( - column: $table.componentType, + ColumnOrderings get baselineEtag => $composableBuilder( + column: $table.baselineEtag, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get uid => $composableBuilder( - column: $table.uid, + ColumnOrderings get baselineRawIcs => $composableBuilder( + column: $table.baselineRawIcs, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get recurrenceIdKey => $composableBuilder( - column: $table.recurrenceIdKey, + ColumnOrderings get localCandidateRawIcs => $composableBuilder( + column: $table.localCandidateRawIcs, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get sequence => $composableBuilder( - column: $table.sequence, + ColumnOrderings get remoteEtag => $composableBuilder( + column: $table.remoteEtag, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get dtstampUtc => $composableBuilder( - column: $table.dtstampUtc, + ColumnOrderings get remoteRawIcs => $composableBuilder( + column: $table.remoteRawIcs, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get lastModifiedUtc => $composableBuilder( - column: $table.lastModifiedUtc, + ColumnOrderings get conflictCode => $composableBuilder( + column: $table.conflictCode, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get semanticHash => $composableBuilder( - column: $table.semanticHash, + ColumnOrderings get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get parserProfileVersion => $composableBuilder( - column: $table.parserProfileVersion, + ColumnOrderings get resolvedAtUtc => $composableBuilder( + column: $table.resolvedAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get resolution => $composableBuilder( + column: $table.resolution, builder: (column) => ColumnOrderings(column), ); + $$AccountsTableOrderingComposer get accountId { + final $$AccountsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableOrderingComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$DavCollectionsTableOrderingComposer get davCollectionId { + final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavCollectionsTableOrderingComposer( + $db: $db, + $table: $db.davCollections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + $$DavObjectsTableOrderingComposer get davObjectId { final $$DavObjectsTableOrderingComposer composer = $composerBuilder( composer: this, @@ -28455,9 +31523,9 @@ class $$DavObjectComponentsTableOrderingComposer } } -class $$DavObjectComponentsTableAnnotationComposer - extends Composer<_$AppDatabase, $DavObjectComponentsTable> { - $$DavObjectComponentsTableAnnotationComposer({ +class $$DavConflictSnapshotsTableAnnotationComposer + extends Composer<_$AppDatabase, $DavConflictSnapshotsTable> { + $$DavConflictSnapshotsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -28467,56 +31535,65 @@ class $$DavObjectComponentsTableAnnotationComposer GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get componentType => $composableBuilder( - column: $table.componentType, + GeneratedColumn get baselineEtag => $composableBuilder( + column: $table.baselineEtag, builder: (column) => column, ); - GeneratedColumn get uid => - $composableBuilder(column: $table.uid, builder: (column) => column); + GeneratedColumn get baselineRawIcs => $composableBuilder( + column: $table.baselineRawIcs, + builder: (column) => column, + ); - GeneratedColumn get recurrenceIdKey => $composableBuilder( - column: $table.recurrenceIdKey, + GeneratedColumn get localCandidateRawIcs => $composableBuilder( + column: $table.localCandidateRawIcs, builder: (column) => column, ); - GeneratedColumn get sequence => - $composableBuilder(column: $table.sequence, builder: (column) => column); + GeneratedColumn get remoteEtag => $composableBuilder( + column: $table.remoteEtag, + builder: (column) => column, + ); - GeneratedColumn get dtstampUtc => $composableBuilder( - column: $table.dtstampUtc, + GeneratedColumn get remoteRawIcs => $composableBuilder( + column: $table.remoteRawIcs, builder: (column) => column, ); - GeneratedColumn get lastModifiedUtc => $composableBuilder( - column: $table.lastModifiedUtc, + GeneratedColumn get conflictCode => $composableBuilder( + column: $table.conflictCode, builder: (column) => column, ); - GeneratedColumn get semanticHash => $composableBuilder( - column: $table.semanticHash, + GeneratedColumn get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, builder: (column) => column, ); - GeneratedColumn get parserProfileVersion => $composableBuilder( - column: $table.parserProfileVersion, + GeneratedColumn get resolvedAtUtc => $composableBuilder( + column: $table.resolvedAtUtc, builder: (column) => column, ); - $$DavObjectsTableAnnotationComposer get davObjectId { - final $$DavObjectsTableAnnotationComposer composer = $composerBuilder( + GeneratedColumn get resolution => $composableBuilder( + column: $table.resolution, + builder: (column) => column, + ); + + $$AccountsTableAnnotationComposer get accountId { + final $$AccountsTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.davObjectId, - referencedTable: $db.davObjects, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableAnnotationComposer( + }) => $$AccountsTableAnnotationComposer( $db: $db, - $table: $db.davObjects, + $table: $db.accounts, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -28526,47 +31603,68 @@ class $$DavObjectComponentsTableAnnotationComposer return composer; } - Expression tasksRefs( - Expression Function($$TasksTableAnnotationComposer a) f, - ) { - final $$TasksTableAnnotationComposer composer = $composerBuilder( + $$DavCollectionsTableAnnotationComposer get davCollectionId { + final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.tasks, - getReferencedColumn: (t) => t.davComponentId, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$TasksTableAnnotationComposer( + }) => $$DavCollectionsTableAnnotationComposer( $db: $db, - $table: $db.tasks, + $table: $db.davCollections, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, ), ); - return f(composer); + return composer; } - Expression calendarEventsRefs( - Expression Function($$CalendarEventsTableAnnotationComposer a) f, + $$DavObjectsTableAnnotationComposer get davObjectId { + final $$DavObjectsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davObjectId, + referencedTable: $db.davObjects, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectsTableAnnotationComposer( + $db: $db, + $table: $db.davObjects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + Expression pendingOpsRefs( + Expression Function($$PendingOpsTableAnnotationComposer a) f, ) { - final $$CalendarEventsTableAnnotationComposer composer = $composerBuilder( + final $$PendingOpsTableAnnotationComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarEvents, - getReferencedColumn: (t) => t.davComponentId, + referencedTable: $db.pendingOps, + getReferencedColumn: (t) => t.conflictSnapshotId, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventsTableAnnotationComposer( + }) => $$PendingOpsTableAnnotationComposer( $db: $db, - $table: $db.calendarEvents, + $table: $db.pendingOps, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -28577,116 +31675,127 @@ class $$DavObjectComponentsTableAnnotationComposer } } -class $$DavObjectComponentsTableTableManager +class $$DavConflictSnapshotsTableTableManager extends RootTableManager< _$AppDatabase, - $DavObjectComponentsTable, - DavObjectComponent, - $$DavObjectComponentsTableFilterComposer, - $$DavObjectComponentsTableOrderingComposer, - $$DavObjectComponentsTableAnnotationComposer, - $$DavObjectComponentsTableCreateCompanionBuilder, - $$DavObjectComponentsTableUpdateCompanionBuilder, - (DavObjectComponent, $$DavObjectComponentsTableReferences), - DavObjectComponent, + $DavConflictSnapshotsTable, + DavConflictSnapshot, + $$DavConflictSnapshotsTableFilterComposer, + $$DavConflictSnapshotsTableOrderingComposer, + $$DavConflictSnapshotsTableAnnotationComposer, + $$DavConflictSnapshotsTableCreateCompanionBuilder, + $$DavConflictSnapshotsTableUpdateCompanionBuilder, + (DavConflictSnapshot, $$DavConflictSnapshotsTableReferences), + DavConflictSnapshot, PrefetchHooks Function({ + bool accountId, + bool davCollectionId, bool davObjectId, - bool tasksRefs, - bool calendarEventsRefs, + bool pendingOpsRefs, }) > { - $$DavObjectComponentsTableTableManager( + $$DavConflictSnapshotsTableTableManager( _$AppDatabase db, - $DavObjectComponentsTable table, + $DavConflictSnapshotsTable table, ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$DavObjectComponentsTableFilterComposer($db: db, $table: table), + $$DavConflictSnapshotsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$DavObjectComponentsTableOrderingComposer( + $$DavConflictSnapshotsTableOrderingComposer( $db: db, $table: table, ), createComputedFieldComposer: () => - $$DavObjectComponentsTableAnnotationComposer( + $$DavConflictSnapshotsTableAnnotationComposer( $db: db, $table: table, ), updateCompanionCallback: ({ Value id = const Value.absent(), - Value davObjectId = const Value.absent(), - Value componentType = const Value.absent(), - Value uid = const Value.absent(), - Value recurrenceIdKey = const Value.absent(), - Value sequence = const Value.absent(), - Value dtstampUtc = const Value.absent(), - Value lastModifiedUtc = const Value.absent(), - Value semanticHash = const Value.absent(), - Value parserProfileVersion = const Value.absent(), + Value accountId = const Value.absent(), + Value davCollectionId = const Value.absent(), + Value davObjectId = const Value.absent(), + Value baselineEtag = const Value.absent(), + Value baselineRawIcs = const Value.absent(), + Value localCandidateRawIcs = const Value.absent(), + Value remoteEtag = const Value.absent(), + Value remoteRawIcs = const Value.absent(), + Value conflictCode = const Value.absent(), + Value createdAtUtc = const Value.absent(), + Value resolvedAtUtc = const Value.absent(), + Value resolution = const Value.absent(), Value rowid = const Value.absent(), - }) => DavObjectComponentsCompanion( + }) => DavConflictSnapshotsCompanion( id: id, + accountId: accountId, + davCollectionId: davCollectionId, davObjectId: davObjectId, - componentType: componentType, - uid: uid, - recurrenceIdKey: recurrenceIdKey, - sequence: sequence, - dtstampUtc: dtstampUtc, - lastModifiedUtc: lastModifiedUtc, - semanticHash: semanticHash, - parserProfileVersion: parserProfileVersion, + baselineEtag: baselineEtag, + baselineRawIcs: baselineRawIcs, + localCandidateRawIcs: localCandidateRawIcs, + remoteEtag: remoteEtag, + remoteRawIcs: remoteRawIcs, + conflictCode: conflictCode, + createdAtUtc: createdAtUtc, + resolvedAtUtc: resolvedAtUtc, + resolution: resolution, rowid: rowid, ), createCompanionCallback: ({ required String id, - required String davObjectId, - required String componentType, - required String uid, - Value recurrenceIdKey = const Value.absent(), - Value sequence = const Value.absent(), - Value dtstampUtc = const Value.absent(), - Value lastModifiedUtc = const Value.absent(), - required String semanticHash, - Value parserProfileVersion = const Value.absent(), + required String accountId, + Value davCollectionId = const Value.absent(), + Value davObjectId = const Value.absent(), + Value baselineEtag = const Value.absent(), + required String baselineRawIcs, + required String localCandidateRawIcs, + Value remoteEtag = const Value.absent(), + required String remoteRawIcs, + required String conflictCode, + required String createdAtUtc, + Value resolvedAtUtc = const Value.absent(), + Value resolution = const Value.absent(), Value rowid = const Value.absent(), - }) => DavObjectComponentsCompanion.insert( + }) => DavConflictSnapshotsCompanion.insert( id: id, + accountId: accountId, + davCollectionId: davCollectionId, davObjectId: davObjectId, - componentType: componentType, - uid: uid, - recurrenceIdKey: recurrenceIdKey, - sequence: sequence, - dtstampUtc: dtstampUtc, - lastModifiedUtc: lastModifiedUtc, - semanticHash: semanticHash, - parserProfileVersion: parserProfileVersion, + baselineEtag: baselineEtag, + baselineRawIcs: baselineRawIcs, + localCandidateRawIcs: localCandidateRawIcs, + remoteEtag: remoteEtag, + remoteRawIcs: remoteRawIcs, + conflictCode: conflictCode, + createdAtUtc: createdAtUtc, + resolvedAtUtc: resolvedAtUtc, + resolution: resolution, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( e.readTable(table), - $$DavObjectComponentsTableReferences(db, table, e), + $$DavConflictSnapshotsTableReferences(db, table, e), ), ) .toList(), prefetchHooksCallback: ({ + accountId = false, + davCollectionId = false, davObjectId = false, - tasksRefs = false, - calendarEventsRefs = false, + pendingOpsRefs = false, }) { return PrefetchHooks( db: db, - explicitlyWatchedTables: [ - if (tasksRefs) db.tasks, - if (calendarEventsRefs) db.calendarEvents, - ], + explicitlyWatchedTables: [if (pendingOpsRefs) db.pendingOps], addJoins: < T extends TableManagerState< @@ -28703,16 +31812,46 @@ class $$DavObjectComponentsTableTableManager dynamic > >(state) { + if (accountId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: + $$DavConflictSnapshotsTableReferences + ._accountIdTable(db), + referencedColumn: + $$DavConflictSnapshotsTableReferences + ._accountIdTable(db) + .id, + ) + as T; + } + if (davCollectionId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.davCollectionId, + referencedTable: + $$DavConflictSnapshotsTableReferences + ._davCollectionIdTable(db), + referencedColumn: + $$DavConflictSnapshotsTableReferences + ._davCollectionIdTable(db) + .id, + ) + as T; + } if (davObjectId) { state = state.withJoin( currentTable: table, currentColumn: table.davObjectId, referencedTable: - $$DavObjectComponentsTableReferences + $$DavConflictSnapshotsTableReferences ._davObjectIdTable(db), referencedColumn: - $$DavObjectComponentsTableReferences + $$DavConflictSnapshotsTableReferences ._davObjectIdTable(db) .id, ) @@ -28723,45 +31862,24 @@ class $$DavObjectComponentsTableTableManager }, getPrefetchedDataCallback: (items) async { return [ - if (tasksRefs) - await $_getPrefetchedData< - DavObjectComponent, - $DavObjectComponentsTable, - Task - >( - currentTable: table, - referencedTable: $$DavObjectComponentsTableReferences - ._tasksRefsTable(db), - managerFromTypedResult: (p0) => - $$DavObjectComponentsTableReferences( - db, - table, - p0, - ).tasksRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.davComponentId == item.id, - ), - typedResults: items, - ), - if (calendarEventsRefs) + if (pendingOpsRefs) await $_getPrefetchedData< - DavObjectComponent, - $DavObjectComponentsTable, - CalendarEvent + DavConflictSnapshot, + $DavConflictSnapshotsTable, + PendingOp >( currentTable: table, - referencedTable: $$DavObjectComponentsTableReferences - ._calendarEventsRefsTable(db), + referencedTable: $$DavConflictSnapshotsTableReferences + ._pendingOpsRefsTable(db), managerFromTypedResult: (p0) => - $$DavObjectComponentsTableReferences( + $$DavConflictSnapshotsTableReferences( db, table, p0, - ).calendarEventsRefs, + ).pendingOpsRefs, referencedItemsForCurrentItem: (item, referencedItems) => referencedItems.where( - (e) => e.davComponentId == item.id, + (e) => e.conflictSnapshotId == item.id, ), typedResults: items, ), @@ -28773,75 +31891,83 @@ class $$DavObjectComponentsTableTableManager ); } -typedef $$DavObjectComponentsTableProcessedTableManager = +typedef $$DavConflictSnapshotsTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $DavObjectComponentsTable, - DavObjectComponent, - $$DavObjectComponentsTableFilterComposer, - $$DavObjectComponentsTableOrderingComposer, - $$DavObjectComponentsTableAnnotationComposer, - $$DavObjectComponentsTableCreateCompanionBuilder, - $$DavObjectComponentsTableUpdateCompanionBuilder, - (DavObjectComponent, $$DavObjectComponentsTableReferences), - DavObjectComponent, + $DavConflictSnapshotsTable, + DavConflictSnapshot, + $$DavConflictSnapshotsTableFilterComposer, + $$DavConflictSnapshotsTableOrderingComposer, + $$DavConflictSnapshotsTableAnnotationComposer, + $$DavConflictSnapshotsTableCreateCompanionBuilder, + $$DavConflictSnapshotsTableUpdateCompanionBuilder, + (DavConflictSnapshot, $$DavConflictSnapshotsTableReferences), + DavConflictSnapshot, PrefetchHooks Function({ + bool accountId, + bool davCollectionId, bool davObjectId, - bool tasksRefs, - bool calendarEventsRefs, + bool pendingOpsRefs, }) >; -typedef $$DavConflictSnapshotsTableCreateCompanionBuilder = - DavConflictSnapshotsCompanion Function({ - required String id, +typedef $$TaskListsTableCreateCompanionBuilder = + TaskListsCompanion Function({ required String accountId, + required String id, Value davCollectionId, - Value davObjectId, - Value baselineEtag, - required String baselineRawIcs, - required String localCandidateRawIcs, - Value remoteEtag, - required String remoteRawIcs, - required String conflictCode, - required String createdAtUtc, - Value resolvedAtUtc, - Value resolution, + Value kind, + Value etag, + required String title, + Value updatedUtc, + Value selfLink, + required String rawJson, + Value providerListKind, + Value isOwner, + Value isShared, + Value deltaLink, + Value providerMetadataJson, + Value serverMissing, + Value localDirty, + Value pendingDelete, + Value remindersEnabled, + Value lastSyncedAtUtc, + required String createdLocalAtUtc, + required String updatedLocalAtUtc, Value rowid, }); -typedef $$DavConflictSnapshotsTableUpdateCompanionBuilder = - DavConflictSnapshotsCompanion Function({ - Value id, +typedef $$TaskListsTableUpdateCompanionBuilder = + TaskListsCompanion Function({ Value accountId, + Value id, Value davCollectionId, - Value davObjectId, - Value baselineEtag, - Value baselineRawIcs, - Value localCandidateRawIcs, - Value remoteEtag, - Value remoteRawIcs, - Value conflictCode, - Value createdAtUtc, - Value resolvedAtUtc, - Value resolution, + Value kind, + Value etag, + Value title, + Value updatedUtc, + Value selfLink, + Value rawJson, + Value providerListKind, + Value isOwner, + Value isShared, + Value deltaLink, + Value providerMetadataJson, + Value serverMissing, + Value localDirty, + Value pendingDelete, + Value remindersEnabled, + Value lastSyncedAtUtc, + Value createdLocalAtUtc, + Value updatedLocalAtUtc, Value rowid, }); -final class $$DavConflictSnapshotsTableReferences - extends - BaseReferences< - _$AppDatabase, - $DavConflictSnapshotsTable, - DavConflictSnapshot - > { - $$DavConflictSnapshotsTableReferences( - super.$_db, - super.$_table, - super.$_typedResult, - ); +final class $$TaskListsTableReferences + extends BaseReferences<_$AppDatabase, $TaskListsTable, TaskList> { + $$TaskListsTableReferences(super.$_db, super.$_table, super.$_typedResult); static $AccountsTable _accountIdTable(_$AppDatabase db) => db.accounts.createAlias( - $_aliasNameGenerator(db.davConflictSnapshots.accountId, db.accounts.id), + $_aliasNameGenerator(db.taskLists.accountId, db.accounts.id), ); $$AccountsTableProcessedTableManager get accountId { @@ -28861,7 +31987,7 @@ final class $$DavConflictSnapshotsTableReferences static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => db.davCollections.createAlias( $_aliasNameGenerator( - db.davConflictSnapshots.davCollectionId, + db.taskLists.davCollectionId, db.davCollections.id, ), ); @@ -28879,53 +32005,11 @@ final class $$DavConflictSnapshotsTableReferences manager.$state.copyWith(prefetchedData: [item]), ); } - - static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => - db.davObjects.createAlias( - $_aliasNameGenerator( - db.davConflictSnapshots.davObjectId, - db.davObjects.id, - ), - ); - - $$DavObjectsTableProcessedTableManager? get davObjectId { - final $_column = $_itemColumn('dav_object_id'); - if ($_column == null) return null; - final manager = $$DavObjectsTableTableManager( - $_db, - $_db.davObjects, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_davObjectIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } - - static MultiTypedResultKey<$PendingOpsTable, List> - _pendingOpsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.pendingOps, - aliasName: $_aliasNameGenerator( - db.davConflictSnapshots.id, - db.pendingOps.conflictSnapshotId, - ), - ); - - $$PendingOpsTableProcessedTableManager get pendingOpsRefs { - final manager = $$PendingOpsTableTableManager($_db, $_db.pendingOps).filter( - (f) => f.conflictSnapshotId.id.sqlEquals($_itemColumn('id')!), - ); - - final cache = $_typedResult.readTableOrNull(_pendingOpsRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); - } } -class $$DavConflictSnapshotsTableFilterComposer - extends Composer<_$AppDatabase, $DavConflictSnapshotsTable> { - $$DavConflictSnapshotsTableFilterComposer({ +class $$TaskListsTableFilterComposer + extends Composer<_$AppDatabase, $TaskListsTable> { + $$TaskListsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -28937,48 +32021,93 @@ class $$DavConflictSnapshotsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get baselineEtag => $composableBuilder( - column: $table.baselineEtag, + ColumnFilters get kind => $composableBuilder( + column: $table.kind, builder: (column) => ColumnFilters(column), ); - ColumnFilters get baselineRawIcs => $composableBuilder( - column: $table.baselineRawIcs, + ColumnFilters get etag => $composableBuilder( + column: $table.etag, builder: (column) => ColumnFilters(column), ); - ColumnFilters get localCandidateRawIcs => $composableBuilder( - column: $table.localCandidateRawIcs, + ColumnFilters get title => $composableBuilder( + column: $table.title, builder: (column) => ColumnFilters(column), ); - ColumnFilters get remoteEtag => $composableBuilder( - column: $table.remoteEtag, + ColumnFilters get updatedUtc => $composableBuilder( + column: $table.updatedUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get remoteRawIcs => $composableBuilder( - column: $table.remoteRawIcs, + ColumnFilters get selfLink => $composableBuilder( + column: $table.selfLink, builder: (column) => ColumnFilters(column), ); - ColumnFilters get conflictCode => $composableBuilder( - column: $table.conflictCode, + ColumnFilters get rawJson => $composableBuilder( + column: $table.rawJson, builder: (column) => ColumnFilters(column), ); - ColumnFilters get createdAtUtc => $composableBuilder( - column: $table.createdAtUtc, + ColumnFilters get providerListKind => $composableBuilder( + column: $table.providerListKind, builder: (column) => ColumnFilters(column), ); - ColumnFilters get resolvedAtUtc => $composableBuilder( - column: $table.resolvedAtUtc, + ColumnFilters get isOwner => $composableBuilder( + column: $table.isOwner, builder: (column) => ColumnFilters(column), ); - ColumnFilters get resolution => $composableBuilder( - column: $table.resolution, + ColumnFilters get isShared => $composableBuilder( + column: $table.isShared, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get deltaLink => $composableBuilder( + column: $table.deltaLink, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get providerMetadataJson => $composableBuilder( + column: $table.providerMetadataJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get serverMissing => $composableBuilder( + column: $table.serverMissing, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get localDirty => $composableBuilder( + column: $table.localDirty, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get pendingDelete => $composableBuilder( + column: $table.pendingDelete, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get remindersEnabled => $composableBuilder( + column: $table.remindersEnabled, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastSyncedAtUtc => $composableBuilder( + column: $table.lastSyncedAtUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdLocalAtUtc => $composableBuilder( + column: $table.createdLocalAtUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedLocalAtUtc => $composableBuilder( + column: $table.updatedLocalAtUtc, builder: (column) => ColumnFilters(column), ); @@ -29027,59 +32156,11 @@ class $$DavConflictSnapshotsTableFilterComposer ); return composer; } - - $$DavObjectsTableFilterComposer get davObjectId { - final $$DavObjectsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davObjectId, - referencedTable: $db.davObjects, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableFilterComposer( - $db: $db, - $table: $db.davObjects, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - Expression pendingOpsRefs( - Expression Function($$PendingOpsTableFilterComposer f) f, - ) { - final $$PendingOpsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.pendingOps, - getReferencedColumn: (t) => t.conflictSnapshotId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$PendingOpsTableFilterComposer( - $db: $db, - $table: $db.pendingOps, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } } -class $$DavConflictSnapshotsTableOrderingComposer - extends Composer<_$AppDatabase, $DavConflictSnapshotsTable> { - $$DavConflictSnapshotsTableOrderingComposer({ +class $$TaskListsTableOrderingComposer + extends Composer<_$AppDatabase, $TaskListsTable> { + $$TaskListsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -29091,48 +32172,93 @@ class $$DavConflictSnapshotsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get baselineEtag => $composableBuilder( - column: $table.baselineEtag, + ColumnOrderings get kind => $composableBuilder( + column: $table.kind, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get baselineRawIcs => $composableBuilder( - column: $table.baselineRawIcs, + ColumnOrderings get etag => $composableBuilder( + column: $table.etag, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get localCandidateRawIcs => $composableBuilder( - column: $table.localCandidateRawIcs, + ColumnOrderings get title => $composableBuilder( + column: $table.title, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get remoteEtag => $composableBuilder( - column: $table.remoteEtag, + ColumnOrderings get updatedUtc => $composableBuilder( + column: $table.updatedUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get remoteRawIcs => $composableBuilder( - column: $table.remoteRawIcs, + ColumnOrderings get selfLink => $composableBuilder( + column: $table.selfLink, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get conflictCode => $composableBuilder( - column: $table.conflictCode, + ColumnOrderings get rawJson => $composableBuilder( + column: $table.rawJson, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get createdAtUtc => $composableBuilder( - column: $table.createdAtUtc, + ColumnOrderings get providerListKind => $composableBuilder( + column: $table.providerListKind, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get resolvedAtUtc => $composableBuilder( - column: $table.resolvedAtUtc, + ColumnOrderings get isOwner => $composableBuilder( + column: $table.isOwner, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get resolution => $composableBuilder( - column: $table.resolution, + ColumnOrderings get isShared => $composableBuilder( + column: $table.isShared, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get deltaLink => $composableBuilder( + column: $table.deltaLink, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get providerMetadataJson => $composableBuilder( + column: $table.providerMetadataJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get serverMissing => $composableBuilder( + column: $table.serverMissing, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get localDirty => $composableBuilder( + column: $table.localDirty, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get pendingDelete => $composableBuilder( + column: $table.pendingDelete, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get remindersEnabled => $composableBuilder( + column: $table.remindersEnabled, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastSyncedAtUtc => $composableBuilder( + column: $table.lastSyncedAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdLocalAtUtc => $composableBuilder( + column: $table.createdLocalAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedLocalAtUtc => $composableBuilder( + column: $table.updatedLocalAtUtc, builder: (column) => ColumnOrderings(column), ); @@ -29162,40 +32288,17 @@ class $$DavConflictSnapshotsTableOrderingComposer $$DavCollectionsTableOrderingComposer get davCollectionId { final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableOrderingComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavObjectsTableOrderingComposer get davObjectId { - final $$DavObjectsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davObjectId, - referencedTable: $db.davObjects, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableOrderingComposer( + }) => $$DavCollectionsTableOrderingComposer( $db: $db, - $table: $db.davObjects, + $table: $db.davCollections, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -29206,9 +32309,9 @@ class $$DavConflictSnapshotsTableOrderingComposer } } -class $$DavConflictSnapshotsTableAnnotationComposer - extends Composer<_$AppDatabase, $DavConflictSnapshotsTable> { - $$DavConflictSnapshotsTableAnnotationComposer({ +class $$TaskListsTableAnnotationComposer + extends Composer<_$AppDatabase, $TaskListsTable> { + $$TaskListsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -29218,48 +32321,77 @@ class $$DavConflictSnapshotsTableAnnotationComposer GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get baselineEtag => $composableBuilder( - column: $table.baselineEtag, + GeneratedColumn get kind => + $composableBuilder(column: $table.kind, builder: (column) => column); + + GeneratedColumn get etag => + $composableBuilder(column: $table.etag, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get updatedUtc => $composableBuilder( + column: $table.updatedUtc, builder: (column) => column, ); - GeneratedColumn get baselineRawIcs => $composableBuilder( - column: $table.baselineRawIcs, + GeneratedColumn get selfLink => + $composableBuilder(column: $table.selfLink, builder: (column) => column); + + GeneratedColumn get rawJson => + $composableBuilder(column: $table.rawJson, builder: (column) => column); + + GeneratedColumn get providerListKind => $composableBuilder( + column: $table.providerListKind, builder: (column) => column, ); - GeneratedColumn get localCandidateRawIcs => $composableBuilder( - column: $table.localCandidateRawIcs, + GeneratedColumn get isOwner => + $composableBuilder(column: $table.isOwner, builder: (column) => column); + + GeneratedColumn get isShared => + $composableBuilder(column: $table.isShared, builder: (column) => column); + + GeneratedColumn get deltaLink => + $composableBuilder(column: $table.deltaLink, builder: (column) => column); + + GeneratedColumn get providerMetadataJson => $composableBuilder( + column: $table.providerMetadataJson, builder: (column) => column, ); - GeneratedColumn get remoteEtag => $composableBuilder( - column: $table.remoteEtag, + GeneratedColumn get serverMissing => $composableBuilder( + column: $table.serverMissing, builder: (column) => column, ); - GeneratedColumn get remoteRawIcs => $composableBuilder( - column: $table.remoteRawIcs, + GeneratedColumn get localDirty => $composableBuilder( + column: $table.localDirty, builder: (column) => column, ); - GeneratedColumn get conflictCode => $composableBuilder( - column: $table.conflictCode, + GeneratedColumn get pendingDelete => $composableBuilder( + column: $table.pendingDelete, builder: (column) => column, ); - GeneratedColumn get createdAtUtc => $composableBuilder( - column: $table.createdAtUtc, + GeneratedColumn get remindersEnabled => $composableBuilder( + column: $table.remindersEnabled, builder: (column) => column, ); - GeneratedColumn get resolvedAtUtc => $composableBuilder( - column: $table.resolvedAtUtc, + GeneratedColumn get lastSyncedAtUtc => $composableBuilder( + column: $table.lastSyncedAtUtc, builder: (column) => column, ); - GeneratedColumn get resolution => $composableBuilder( - column: $table.resolution, + GeneratedColumn get createdLocalAtUtc => $composableBuilder( + column: $table.createdLocalAtUtc, + builder: (column) => column, + ); + + GeneratedColumn get updatedLocalAtUtc => $composableBuilder( + column: $table.updatedLocalAtUtc, builder: (column) => column, ); @@ -29308,177 +32440,143 @@ class $$DavConflictSnapshotsTableAnnotationComposer ); return composer; } - - $$DavObjectsTableAnnotationComposer get davObjectId { - final $$DavObjectsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davObjectId, - referencedTable: $db.davObjects, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableAnnotationComposer( - $db: $db, - $table: $db.davObjects, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - Expression pendingOpsRefs( - Expression Function($$PendingOpsTableAnnotationComposer a) f, - ) { - final $$PendingOpsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.pendingOps, - getReferencedColumn: (t) => t.conflictSnapshotId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$PendingOpsTableAnnotationComposer( - $db: $db, - $table: $db.pendingOps, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } } -class $$DavConflictSnapshotsTableTableManager +class $$TaskListsTableTableManager extends RootTableManager< _$AppDatabase, - $DavConflictSnapshotsTable, - DavConflictSnapshot, - $$DavConflictSnapshotsTableFilterComposer, - $$DavConflictSnapshotsTableOrderingComposer, - $$DavConflictSnapshotsTableAnnotationComposer, - $$DavConflictSnapshotsTableCreateCompanionBuilder, - $$DavConflictSnapshotsTableUpdateCompanionBuilder, - (DavConflictSnapshot, $$DavConflictSnapshotsTableReferences), - DavConflictSnapshot, - PrefetchHooks Function({ - bool accountId, - bool davCollectionId, - bool davObjectId, - bool pendingOpsRefs, - }) + $TaskListsTable, + TaskList, + $$TaskListsTableFilterComposer, + $$TaskListsTableOrderingComposer, + $$TaskListsTableAnnotationComposer, + $$TaskListsTableCreateCompanionBuilder, + $$TaskListsTableUpdateCompanionBuilder, + (TaskList, $$TaskListsTableReferences), + TaskList, + PrefetchHooks Function({bool accountId, bool davCollectionId}) > { - $$DavConflictSnapshotsTableTableManager( - _$AppDatabase db, - $DavConflictSnapshotsTable table, - ) : super( + $$TaskListsTableTableManager(_$AppDatabase db, $TaskListsTable table) + : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$DavConflictSnapshotsTableFilterComposer($db: db, $table: table), + $$TaskListsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$DavConflictSnapshotsTableOrderingComposer( - $db: db, - $table: table, - ), + $$TaskListsTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$DavConflictSnapshotsTableAnnotationComposer( - $db: db, - $table: table, - ), + $$TaskListsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ - Value id = const Value.absent(), Value accountId = const Value.absent(), + Value id = const Value.absent(), Value davCollectionId = const Value.absent(), - Value davObjectId = const Value.absent(), - Value baselineEtag = const Value.absent(), - Value baselineRawIcs = const Value.absent(), - Value localCandidateRawIcs = const Value.absent(), - Value remoteEtag = const Value.absent(), - Value remoteRawIcs = const Value.absent(), - Value conflictCode = const Value.absent(), - Value createdAtUtc = const Value.absent(), - Value resolvedAtUtc = const Value.absent(), - Value resolution = const Value.absent(), + Value kind = const Value.absent(), + Value etag = const Value.absent(), + Value title = const Value.absent(), + Value updatedUtc = const Value.absent(), + Value selfLink = const Value.absent(), + Value rawJson = const Value.absent(), + Value providerListKind = const Value.absent(), + Value isOwner = const Value.absent(), + Value isShared = const Value.absent(), + Value deltaLink = const Value.absent(), + Value providerMetadataJson = const Value.absent(), + Value serverMissing = const Value.absent(), + Value localDirty = const Value.absent(), + Value pendingDelete = const Value.absent(), + Value remindersEnabled = const Value.absent(), + Value lastSyncedAtUtc = const Value.absent(), + Value createdLocalAtUtc = const Value.absent(), + Value updatedLocalAtUtc = const Value.absent(), Value rowid = const Value.absent(), - }) => DavConflictSnapshotsCompanion( - id: id, + }) => TaskListsCompanion( accountId: accountId, + id: id, davCollectionId: davCollectionId, - davObjectId: davObjectId, - baselineEtag: baselineEtag, - baselineRawIcs: baselineRawIcs, - localCandidateRawIcs: localCandidateRawIcs, - remoteEtag: remoteEtag, - remoteRawIcs: remoteRawIcs, - conflictCode: conflictCode, - createdAtUtc: createdAtUtc, - resolvedAtUtc: resolvedAtUtc, - resolution: resolution, + kind: kind, + etag: etag, + title: title, + updatedUtc: updatedUtc, + selfLink: selfLink, + rawJson: rawJson, + providerListKind: providerListKind, + isOwner: isOwner, + isShared: isShared, + deltaLink: deltaLink, + providerMetadataJson: providerMetadataJson, + serverMissing: serverMissing, + localDirty: localDirty, + pendingDelete: pendingDelete, + remindersEnabled: remindersEnabled, + lastSyncedAtUtc: lastSyncedAtUtc, + createdLocalAtUtc: createdLocalAtUtc, + updatedLocalAtUtc: updatedLocalAtUtc, rowid: rowid, ), createCompanionCallback: ({ - required String id, required String accountId, + required String id, Value davCollectionId = const Value.absent(), - Value davObjectId = const Value.absent(), - Value baselineEtag = const Value.absent(), - required String baselineRawIcs, - required String localCandidateRawIcs, - Value remoteEtag = const Value.absent(), - required String remoteRawIcs, - required String conflictCode, - required String createdAtUtc, - Value resolvedAtUtc = const Value.absent(), - Value resolution = const Value.absent(), + Value kind = const Value.absent(), + Value etag = const Value.absent(), + required String title, + Value updatedUtc = const Value.absent(), + Value selfLink = const Value.absent(), + required String rawJson, + Value providerListKind = const Value.absent(), + Value isOwner = const Value.absent(), + Value isShared = const Value.absent(), + Value deltaLink = const Value.absent(), + Value providerMetadataJson = const Value.absent(), + Value serverMissing = const Value.absent(), + Value localDirty = const Value.absent(), + Value pendingDelete = const Value.absent(), + Value remindersEnabled = const Value.absent(), + Value lastSyncedAtUtc = const Value.absent(), + required String createdLocalAtUtc, + required String updatedLocalAtUtc, Value rowid = const Value.absent(), - }) => DavConflictSnapshotsCompanion.insert( - id: id, + }) => TaskListsCompanion.insert( accountId: accountId, + id: id, davCollectionId: davCollectionId, - davObjectId: davObjectId, - baselineEtag: baselineEtag, - baselineRawIcs: baselineRawIcs, - localCandidateRawIcs: localCandidateRawIcs, - remoteEtag: remoteEtag, - remoteRawIcs: remoteRawIcs, - conflictCode: conflictCode, - createdAtUtc: createdAtUtc, - resolvedAtUtc: resolvedAtUtc, - resolution: resolution, + kind: kind, + etag: etag, + title: title, + updatedUtc: updatedUtc, + selfLink: selfLink, + rawJson: rawJson, + providerListKind: providerListKind, + isOwner: isOwner, + isShared: isShared, + deltaLink: deltaLink, + providerMetadataJson: providerMetadataJson, + serverMissing: serverMissing, + localDirty: localDirty, + pendingDelete: pendingDelete, + remindersEnabled: remindersEnabled, + lastSyncedAtUtc: lastSyncedAtUtc, + createdLocalAtUtc: createdLocalAtUtc, + updatedLocalAtUtc: updatedLocalAtUtc, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( e.readTable(table), - $$DavConflictSnapshotsTableReferences(db, table, e), + $$TaskListsTableReferences(db, table, e), ), ) .toList(), prefetchHooksCallback: - ({ - accountId = false, - davCollectionId = false, - davObjectId = false, - pendingOpsRefs = false, - }) { + ({accountId = false, davCollectionId = false}) { return PrefetchHooks( db: db, - explicitlyWatchedTables: [if (pendingOpsRefs) db.pendingOps], + explicitlyWatchedTables: [], addJoins: < T extends TableManagerState< @@ -29500,13 +32598,11 @@ class $$DavConflictSnapshotsTableTableManager state.withJoin( currentTable: table, currentColumn: table.accountId, - referencedTable: - $$DavConflictSnapshotsTableReferences - ._accountIdTable(db), - referencedColumn: - $$DavConflictSnapshotsTableReferences - ._accountIdTable(db) - .id, + referencedTable: $$TaskListsTableReferences + ._accountIdTable(db), + referencedColumn: $$TaskListsTableReferences + ._accountIdTable(db) + .id, ) as T; } @@ -29515,28 +32611,11 @@ class $$DavConflictSnapshotsTableTableManager state.withJoin( currentTable: table, currentColumn: table.davCollectionId, - referencedTable: - $$DavConflictSnapshotsTableReferences - ._davCollectionIdTable(db), - referencedColumn: - $$DavConflictSnapshotsTableReferences - ._davCollectionIdTable(db) - .id, - ) - as T; - } - if (davObjectId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davObjectId, - referencedTable: - $$DavConflictSnapshotsTableReferences - ._davObjectIdTable(db), - referencedColumn: - $$DavConflictSnapshotsTableReferences - ._davObjectIdTable(db) - .id, + referencedTable: $$TaskListsTableReferences + ._davCollectionIdTable(db), + referencedColumn: $$TaskListsTableReferences + ._davCollectionIdTable(db) + .id, ) as T; } @@ -29544,29 +32623,7 @@ class $$DavConflictSnapshotsTableTableManager return state; }, getPrefetchedDataCallback: (items) async { - return [ - if (pendingOpsRefs) - await $_getPrefetchedData< - DavConflictSnapshot, - $DavConflictSnapshotsTable, - PendingOp - >( - currentTable: table, - referencedTable: $$DavConflictSnapshotsTableReferences - ._pendingOpsRefsTable(db), - managerFromTypedResult: (p0) => - $$DavConflictSnapshotsTableReferences( - db, - table, - p0, - ).pendingOpsRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.conflictSnapshotId == item.id, - ), - typedResults: items, - ), - ]; + return []; }, ); }, @@ -29574,82 +32631,165 @@ class $$DavConflictSnapshotsTableTableManager ); } -typedef $$DavConflictSnapshotsTableProcessedTableManager = +typedef $$TaskListsTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $DavConflictSnapshotsTable, - DavConflictSnapshot, - $$DavConflictSnapshotsTableFilterComposer, - $$DavConflictSnapshotsTableOrderingComposer, - $$DavConflictSnapshotsTableAnnotationComposer, - $$DavConflictSnapshotsTableCreateCompanionBuilder, - $$DavConflictSnapshotsTableUpdateCompanionBuilder, - (DavConflictSnapshot, $$DavConflictSnapshotsTableReferences), - DavConflictSnapshot, - PrefetchHooks Function({ - bool accountId, - bool davCollectionId, - bool davObjectId, - bool pendingOpsRefs, - }) + $TaskListsTable, + TaskList, + $$TaskListsTableFilterComposer, + $$TaskListsTableOrderingComposer, + $$TaskListsTableAnnotationComposer, + $$TaskListsTableCreateCompanionBuilder, + $$TaskListsTableUpdateCompanionBuilder, + (TaskList, $$TaskListsTableReferences), + TaskList, + PrefetchHooks Function({bool accountId, bool davCollectionId}) >; -typedef $$TaskListsTableCreateCompanionBuilder = - TaskListsCompanion Function({ +typedef $$TasksTableCreateCompanionBuilder = + TasksCompanion Function({ required String accountId, + required String taskListId, required String id, Value davCollectionId, + Value davObjectId, + Value davComponentId, + Value icalUid, + Value recurrenceIdKey, + Value icalPriority, + Value percentComplete, + Value taskLocation, + Value taskUrl, + Value taskClassification, + Value taskPinned, + Value taskHideSubtasks, + Value taskHideCompletedSubtasks, + Value taskAlarmsJson, + Value parentUid, + Value sortOrder, + Value providerExtensionProjectionJson, + Value projectionVersion, Value kind, Value etag, required String title, Value updatedUtc, Value selfLink, - required String rawJson, - Value providerListKind, - Value isOwner, - Value isShared, - Value deltaLink, + Value parent, + Value position, + Value notes, + Value status, + Value dueUtc, + Value completedUtc, + Value providerStatus, + Value bodyContent, + Value bodyContentType, + Value microsoftDueDateTime, + Value microsoftDueTimeZone, + Value microsoftStartDateTime, + Value microsoftStartTimeZone, + Value microsoftReminderDateTime, + Value microsoftReminderTimeZone, + Value microsoftIsReminderOn, + Value microsoftCompletedDateTime, + Value microsoftCompletedTimeZone, + Value microsoftChecklistItemsJson, + Value recurrenceJson, + Value importance, + Value categoriesJson, + Value hasAttachments, Value providerMetadataJson, + Value deleted, + Value hidden, + Value linksJson, + Value webViewLink, + Value assignmentInfoJson, + required String rawJson, Value serverMissing, Value localDirty, Value pendingDelete, + Value pendingMove, + Value localCreated, + Value syncBaseUpdatedUtc, Value lastSyncedAtUtc, required String createdLocalAtUtc, required String updatedLocalAtUtc, Value rowid, }); -typedef $$TaskListsTableUpdateCompanionBuilder = - TaskListsCompanion Function({ +typedef $$TasksTableUpdateCompanionBuilder = + TasksCompanion Function({ Value accountId, + Value taskListId, Value id, Value davCollectionId, + Value davObjectId, + Value davComponentId, + Value icalUid, + Value recurrenceIdKey, + Value icalPriority, + Value percentComplete, + Value taskLocation, + Value taskUrl, + Value taskClassification, + Value taskPinned, + Value taskHideSubtasks, + Value taskHideCompletedSubtasks, + Value taskAlarmsJson, + Value parentUid, + Value sortOrder, + Value providerExtensionProjectionJson, + Value projectionVersion, Value kind, Value etag, Value title, Value updatedUtc, Value selfLink, - Value rawJson, - Value providerListKind, - Value isOwner, - Value isShared, - Value deltaLink, + Value parent, + Value position, + Value notes, + Value status, + Value dueUtc, + Value completedUtc, + Value providerStatus, + Value bodyContent, + Value bodyContentType, + Value microsoftDueDateTime, + Value microsoftDueTimeZone, + Value microsoftStartDateTime, + Value microsoftStartTimeZone, + Value microsoftReminderDateTime, + Value microsoftReminderTimeZone, + Value microsoftIsReminderOn, + Value microsoftCompletedDateTime, + Value microsoftCompletedTimeZone, + Value microsoftChecklistItemsJson, + Value recurrenceJson, + Value importance, + Value categoriesJson, + Value hasAttachments, Value providerMetadataJson, + Value deleted, + Value hidden, + Value linksJson, + Value webViewLink, + Value assignmentInfoJson, + Value rawJson, Value serverMissing, Value localDirty, Value pendingDelete, + Value pendingMove, + Value localCreated, + Value syncBaseUpdatedUtc, Value lastSyncedAtUtc, Value createdLocalAtUtc, Value updatedLocalAtUtc, Value rowid, }); -final class $$TaskListsTableReferences - extends BaseReferences<_$AppDatabase, $TaskListsTable, TaskList> { - $$TaskListsTableReferences(super.$_db, super.$_table, super.$_typedResult); +final class $$TasksTableReferences + extends BaseReferences<_$AppDatabase, $TasksTable, Task> { + $$TasksTableReferences(super.$_db, super.$_table, super.$_typedResult); - static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.taskLists.accountId, db.accounts.id), - ); + static $AccountsTable _accountIdTable(_$AppDatabase db) => db.accounts + .createAlias($_aliasNameGenerator(db.tasks.accountId, db.accounts.id)); $$AccountsTableProcessedTableManager get accountId { final $_column = $_itemColumn('account_id')!; @@ -29667,10 +32807,7 @@ final class $$TaskListsTableReferences static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => db.davCollections.createAlias( - $_aliasNameGenerator( - db.taskLists.davCollectionId, - db.davCollections.id, - ), + $_aliasNameGenerator(db.tasks.davCollectionId, db.davCollections.id), ); $$DavCollectionsTableProcessedTableManager? get davCollectionId { @@ -29686,22 +32823,143 @@ final class $$TaskListsTableReferences manager.$state.copyWith(prefetchedData: [item]), ); } + + static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => + db.davObjects.createAlias( + $_aliasNameGenerator(db.tasks.davObjectId, db.davObjects.id), + ); + + $$DavObjectsTableProcessedTableManager? get davObjectId { + final $_column = $_itemColumn('dav_object_id'); + if ($_column == null) return null; + final manager = $$DavObjectsTableTableManager( + $_db, + $_db.davObjects, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_davObjectIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $DavObjectComponentsTable _davComponentIdTable(_$AppDatabase db) => + db.davObjectComponents.createAlias( + $_aliasNameGenerator( + db.tasks.davComponentId, + db.davObjectComponents.id, + ), + ); + + $$DavObjectComponentsTableProcessedTableManager? get davComponentId { + final $_column = $_itemColumn('dav_component_id'); + if ($_column == null) return null; + final manager = $$DavObjectComponentsTableTableManager( + $_db, + $_db.davObjectComponents, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_davComponentIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } } -class $$TaskListsTableFilterComposer - extends Composer<_$AppDatabase, $TaskListsTable> { - $$TaskListsTableFilterComposer({ +class $$TasksTableFilterComposer extends Composer<_$AppDatabase, $TasksTable> { + $$TasksTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); + ColumnFilters get taskListId => $composableBuilder( + column: $table.taskListId, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get id => $composableBuilder( column: $table.id, builder: (column) => ColumnFilters(column), ); + ColumnFilters get icalUid => $composableBuilder( + column: $table.icalUid, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get recurrenceIdKey => $composableBuilder( + column: $table.recurrenceIdKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get icalPriority => $composableBuilder( + column: $table.icalPriority, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get percentComplete => $composableBuilder( + column: $table.percentComplete, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get taskLocation => $composableBuilder( + column: $table.taskLocation, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get taskUrl => $composableBuilder( + column: $table.taskUrl, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get taskClassification => $composableBuilder( + column: $table.taskClassification, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get taskPinned => $composableBuilder( + column: $table.taskPinned, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get taskHideSubtasks => $composableBuilder( + column: $table.taskHideSubtasks, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get taskHideCompletedSubtasks => $composableBuilder( + column: $table.taskHideCompletedSubtasks, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get taskAlarmsJson => $composableBuilder( + column: $table.taskAlarmsJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get parentUid => $composableBuilder( + column: $table.parentUid, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sortOrder => $composableBuilder( + column: $table.sortOrder, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get providerExtensionProjectionJson => + $composableBuilder( + column: $table.providerExtensionProjectionJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get projectionVersion => $composableBuilder( + column: $table.projectionVersion, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get kind => $composableBuilder( column: $table.kind, builder: (column) => ColumnFilters(column), @@ -29727,28 +32985,118 @@ class $$TaskListsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get rawJson => $composableBuilder( - column: $table.rawJson, + ColumnFilters get parent => $composableBuilder( + column: $table.parent, builder: (column) => ColumnFilters(column), ); - ColumnFilters get providerListKind => $composableBuilder( - column: $table.providerListKind, + ColumnFilters get position => $composableBuilder( + column: $table.position, builder: (column) => ColumnFilters(column), ); - ColumnFilters get isOwner => $composableBuilder( - column: $table.isOwner, + ColumnFilters get notes => $composableBuilder( + column: $table.notes, builder: (column) => ColumnFilters(column), ); - ColumnFilters get isShared => $composableBuilder( - column: $table.isShared, + ColumnFilters get status => $composableBuilder( + column: $table.status, builder: (column) => ColumnFilters(column), ); - ColumnFilters get deltaLink => $composableBuilder( - column: $table.deltaLink, + ColumnFilters get dueUtc => $composableBuilder( + column: $table.dueUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get completedUtc => $composableBuilder( + column: $table.completedUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get providerStatus => $composableBuilder( + column: $table.providerStatus, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get bodyContent => $composableBuilder( + column: $table.bodyContent, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get bodyContentType => $composableBuilder( + column: $table.bodyContentType, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get microsoftDueDateTime => $composableBuilder( + column: $table.microsoftDueDateTime, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get microsoftDueTimeZone => $composableBuilder( + column: $table.microsoftDueTimeZone, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get microsoftStartDateTime => $composableBuilder( + column: $table.microsoftStartDateTime, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get microsoftStartTimeZone => $composableBuilder( + column: $table.microsoftStartTimeZone, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get microsoftReminderDateTime => $composableBuilder( + column: $table.microsoftReminderDateTime, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get microsoftReminderTimeZone => $composableBuilder( + column: $table.microsoftReminderTimeZone, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get microsoftIsReminderOn => $composableBuilder( + column: $table.microsoftIsReminderOn, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get microsoftCompletedDateTime => $composableBuilder( + column: $table.microsoftCompletedDateTime, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get microsoftCompletedTimeZone => $composableBuilder( + column: $table.microsoftCompletedTimeZone, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get microsoftChecklistItemsJson => $composableBuilder( + column: $table.microsoftChecklistItemsJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get recurrenceJson => $composableBuilder( + column: $table.recurrenceJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get importance => $composableBuilder( + column: $table.importance, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get categoriesJson => $composableBuilder( + column: $table.categoriesJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get hasAttachments => $composableBuilder( + column: $table.hasAttachments, builder: (column) => ColumnFilters(column), ); @@ -29757,6 +33105,36 @@ class $$TaskListsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get deleted => $composableBuilder( + column: $table.deleted, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get hidden => $composableBuilder( + column: $table.hidden, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get linksJson => $composableBuilder( + column: $table.linksJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get webViewLink => $composableBuilder( + column: $table.webViewLink, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get assignmentInfoJson => $composableBuilder( + column: $table.assignmentInfoJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get rawJson => $composableBuilder( + column: $table.rawJson, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get serverMissing => $composableBuilder( column: $table.serverMissing, builder: (column) => ColumnFilters(column), @@ -29772,6 +33150,21 @@ class $$TaskListsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get pendingMove => $composableBuilder( + column: $table.pendingMove, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get localCreated => $composableBuilder( + column: $table.localCreated, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get syncBaseUpdatedUtc => $composableBuilder( + column: $table.syncBaseUpdatedUtc, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get lastSyncedAtUtc => $composableBuilder( column: $table.lastSyncedAtUtc, builder: (column) => ColumnFilters(column), @@ -29832,69 +33225,286 @@ class $$TaskListsTableFilterComposer ); return composer; } + + $$DavObjectsTableFilterComposer get davObjectId { + final $$DavObjectsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davObjectId, + referencedTable: $db.davObjects, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectsTableFilterComposer( + $db: $db, + $table: $db.davObjects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$DavObjectComponentsTableFilterComposer get davComponentId { + final $$DavObjectComponentsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davComponentId, + referencedTable: $db.davObjectComponents, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectComponentsTableFilterComposer( + $db: $db, + $table: $db.davObjectComponents, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } } -class $$TaskListsTableOrderingComposer - extends Composer<_$AppDatabase, $TaskListsTable> { - $$TaskListsTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, +class $$TasksTableOrderingComposer + extends Composer<_$AppDatabase, $TasksTable> { + $$TasksTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get taskListId => $composableBuilder( + column: $table.taskListId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get icalUid => $composableBuilder( + column: $table.icalUid, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get recurrenceIdKey => $composableBuilder( + column: $table.recurrenceIdKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get icalPriority => $composableBuilder( + column: $table.icalPriority, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get percentComplete => $composableBuilder( + column: $table.percentComplete, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get taskLocation => $composableBuilder( + column: $table.taskLocation, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get taskUrl => $composableBuilder( + column: $table.taskUrl, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get taskClassification => $composableBuilder( + column: $table.taskClassification, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get taskPinned => $composableBuilder( + column: $table.taskPinned, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get taskHideSubtasks => $composableBuilder( + column: $table.taskHideSubtasks, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get taskHideCompletedSubtasks => $composableBuilder( + column: $table.taskHideCompletedSubtasks, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get taskAlarmsJson => $composableBuilder( + column: $table.taskAlarmsJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get parentUid => $composableBuilder( + column: $table.parentUid, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sortOrder => $composableBuilder( + column: $table.sortOrder, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get providerExtensionProjectionJson => + $composableBuilder( + column: $table.providerExtensionProjectionJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get projectionVersion => $composableBuilder( + column: $table.projectionVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get kind => $composableBuilder( + column: $table.kind, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get etag => $composableBuilder( + column: $table.etag, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedUtc => $composableBuilder( + column: $table.updatedUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get selfLink => $composableBuilder( + column: $table.selfLink, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get parent => $composableBuilder( + column: $table.parent, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get position => $composableBuilder( + column: $table.position, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get notes => $composableBuilder( + column: $table.notes, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get dueUtc => $composableBuilder( + column: $table.dueUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get completedUtc => $composableBuilder( + column: $table.completedUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get providerStatus => $composableBuilder( + column: $table.providerStatus, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get bodyContent => $composableBuilder( + column: $table.bodyContent, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get bodyContentType => $composableBuilder( + column: $table.bodyContentType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get microsoftDueDateTime => $composableBuilder( + column: $table.microsoftDueDateTime, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get microsoftDueTimeZone => $composableBuilder( + column: $table.microsoftDueTimeZone, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get kind => $composableBuilder( - column: $table.kind, + ColumnOrderings get microsoftStartDateTime => $composableBuilder( + column: $table.microsoftStartDateTime, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get etag => $composableBuilder( - column: $table.etag, + ColumnOrderings get microsoftStartTimeZone => $composableBuilder( + column: $table.microsoftStartTimeZone, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get title => $composableBuilder( - column: $table.title, + ColumnOrderings get microsoftReminderDateTime => $composableBuilder( + column: $table.microsoftReminderDateTime, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get updatedUtc => $composableBuilder( - column: $table.updatedUtc, + ColumnOrderings get microsoftReminderTimeZone => $composableBuilder( + column: $table.microsoftReminderTimeZone, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get selfLink => $composableBuilder( - column: $table.selfLink, + ColumnOrderings get microsoftIsReminderOn => $composableBuilder( + column: $table.microsoftIsReminderOn, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get rawJson => $composableBuilder( - column: $table.rawJson, + ColumnOrderings get microsoftCompletedDateTime => $composableBuilder( + column: $table.microsoftCompletedDateTime, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get providerListKind => $composableBuilder( - column: $table.providerListKind, + ColumnOrderings get microsoftCompletedTimeZone => $composableBuilder( + column: $table.microsoftCompletedTimeZone, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get isOwner => $composableBuilder( - column: $table.isOwner, + ColumnOrderings get microsoftChecklistItemsJson => $composableBuilder( + column: $table.microsoftChecklistItemsJson, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get isShared => $composableBuilder( - column: $table.isShared, + ColumnOrderings get recurrenceJson => $composableBuilder( + column: $table.recurrenceJson, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get deltaLink => $composableBuilder( - column: $table.deltaLink, + ColumnOrderings get importance => $composableBuilder( + column: $table.importance, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get categoriesJson => $composableBuilder( + column: $table.categoriesJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get hasAttachments => $composableBuilder( + column: $table.hasAttachments, builder: (column) => ColumnOrderings(column), ); @@ -29903,6 +33513,36 @@ class $$TaskListsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get deleted => $composableBuilder( + column: $table.deleted, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get hidden => $composableBuilder( + column: $table.hidden, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get linksJson => $composableBuilder( + column: $table.linksJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get webViewLink => $composableBuilder( + column: $table.webViewLink, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get assignmentInfoJson => $composableBuilder( + column: $table.assignmentInfoJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get rawJson => $composableBuilder( + column: $table.rawJson, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get serverMissing => $composableBuilder( column: $table.serverMissing, builder: (column) => ColumnOrderings(column), @@ -29918,6 +33558,21 @@ class $$TaskListsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get pendingMove => $composableBuilder( + column: $table.pendingMove, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get localCreated => $composableBuilder( + column: $table.localCreated, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get syncBaseUpdatedUtc => $composableBuilder( + column: $table.syncBaseUpdatedUtc, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get lastSyncedAtUtc => $composableBuilder( column: $table.lastSyncedAtUtc, builder: (column) => ColumnOrderings(column), @@ -29978,20 +33633,140 @@ class $$TaskListsTableOrderingComposer ); return composer; } + + $$DavObjectsTableOrderingComposer get davObjectId { + final $$DavObjectsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davObjectId, + referencedTable: $db.davObjects, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectsTableOrderingComposer( + $db: $db, + $table: $db.davObjects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$DavObjectComponentsTableOrderingComposer get davComponentId { + final $$DavObjectComponentsTableOrderingComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davComponentId, + referencedTable: $db.davObjectComponents, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectComponentsTableOrderingComposer( + $db: $db, + $table: $db.davObjectComponents, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } } -class $$TaskListsTableAnnotationComposer - extends Composer<_$AppDatabase, $TaskListsTable> { - $$TaskListsTableAnnotationComposer({ +class $$TasksTableAnnotationComposer + extends Composer<_$AppDatabase, $TasksTable> { + $$TasksTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); + GeneratedColumn get taskListId => $composableBuilder( + column: $table.taskListId, + builder: (column) => column, + ); + GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); + GeneratedColumn get icalUid => + $composableBuilder(column: $table.icalUid, builder: (column) => column); + + GeneratedColumn get recurrenceIdKey => $composableBuilder( + column: $table.recurrenceIdKey, + builder: (column) => column, + ); + + GeneratedColumn get icalPriority => $composableBuilder( + column: $table.icalPriority, + builder: (column) => column, + ); + + GeneratedColumn get percentComplete => $composableBuilder( + column: $table.percentComplete, + builder: (column) => column, + ); + + GeneratedColumn get taskLocation => $composableBuilder( + column: $table.taskLocation, + builder: (column) => column, + ); + + GeneratedColumn get taskUrl => + $composableBuilder(column: $table.taskUrl, builder: (column) => column); + + GeneratedColumn get taskClassification => $composableBuilder( + column: $table.taskClassification, + builder: (column) => column, + ); + + GeneratedColumn get taskPinned => $composableBuilder( + column: $table.taskPinned, + builder: (column) => column, + ); + + GeneratedColumn get taskHideSubtasks => $composableBuilder( + column: $table.taskHideSubtasks, + builder: (column) => column, + ); + + GeneratedColumn get taskHideCompletedSubtasks => $composableBuilder( + column: $table.taskHideCompletedSubtasks, + builder: (column) => column, + ); + + GeneratedColumn get taskAlarmsJson => $composableBuilder( + column: $table.taskAlarmsJson, + builder: (column) => column, + ); + + GeneratedColumn get parentUid => + $composableBuilder(column: $table.parentUid, builder: (column) => column); + + GeneratedColumn get sortOrder => + $composableBuilder(column: $table.sortOrder, builder: (column) => column); + + GeneratedColumn get providerExtensionProjectionJson => + $composableBuilder( + column: $table.providerExtensionProjectionJson, + builder: (column) => column, + ); + + GeneratedColumn get projectionVersion => $composableBuilder( + column: $table.projectionVersion, + builder: (column) => column, + ); + GeneratedColumn get kind => $composableBuilder(column: $table.kind, builder: (column) => column); @@ -30009,28 +33784,138 @@ class $$TaskListsTableAnnotationComposer GeneratedColumn get selfLink => $composableBuilder(column: $table.selfLink, builder: (column) => column); - GeneratedColumn get rawJson => - $composableBuilder(column: $table.rawJson, builder: (column) => column); + GeneratedColumn get parent => + $composableBuilder(column: $table.parent, builder: (column) => column); - GeneratedColumn get providerListKind => $composableBuilder( - column: $table.providerListKind, + GeneratedColumn get position => + $composableBuilder(column: $table.position, builder: (column) => column); + + GeneratedColumn get notes => + $composableBuilder(column: $table.notes, builder: (column) => column); + + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get dueUtc => + $composableBuilder(column: $table.dueUtc, builder: (column) => column); + + GeneratedColumn get completedUtc => $composableBuilder( + column: $table.completedUtc, builder: (column) => column, ); - GeneratedColumn get isOwner => - $composableBuilder(column: $table.isOwner, builder: (column) => column); + GeneratedColumn get providerStatus => $composableBuilder( + column: $table.providerStatus, + builder: (column) => column, + ); - GeneratedColumn get isShared => - $composableBuilder(column: $table.isShared, builder: (column) => column); + GeneratedColumn get bodyContent => $composableBuilder( + column: $table.bodyContent, + builder: (column) => column, + ); - GeneratedColumn get deltaLink => - $composableBuilder(column: $table.deltaLink, builder: (column) => column); + GeneratedColumn get bodyContentType => $composableBuilder( + column: $table.bodyContentType, + builder: (column) => column, + ); + + GeneratedColumn get microsoftDueDateTime => $composableBuilder( + column: $table.microsoftDueDateTime, + builder: (column) => column, + ); + + GeneratedColumn get microsoftDueTimeZone => $composableBuilder( + column: $table.microsoftDueTimeZone, + builder: (column) => column, + ); + + GeneratedColumn get microsoftStartDateTime => $composableBuilder( + column: $table.microsoftStartDateTime, + builder: (column) => column, + ); + + GeneratedColumn get microsoftStartTimeZone => $composableBuilder( + column: $table.microsoftStartTimeZone, + builder: (column) => column, + ); + + GeneratedColumn get microsoftReminderDateTime => $composableBuilder( + column: $table.microsoftReminderDateTime, + builder: (column) => column, + ); + + GeneratedColumn get microsoftReminderTimeZone => $composableBuilder( + column: $table.microsoftReminderTimeZone, + builder: (column) => column, + ); + + GeneratedColumn get microsoftIsReminderOn => $composableBuilder( + column: $table.microsoftIsReminderOn, + builder: (column) => column, + ); + + GeneratedColumn get microsoftCompletedDateTime => $composableBuilder( + column: $table.microsoftCompletedDateTime, + builder: (column) => column, + ); + + GeneratedColumn get microsoftCompletedTimeZone => $composableBuilder( + column: $table.microsoftCompletedTimeZone, + builder: (column) => column, + ); + + GeneratedColumn get microsoftChecklistItemsJson => $composableBuilder( + column: $table.microsoftChecklistItemsJson, + builder: (column) => column, + ); + + GeneratedColumn get recurrenceJson => $composableBuilder( + column: $table.recurrenceJson, + builder: (column) => column, + ); + + GeneratedColumn get importance => $composableBuilder( + column: $table.importance, + builder: (column) => column, + ); + + GeneratedColumn get categoriesJson => $composableBuilder( + column: $table.categoriesJson, + builder: (column) => column, + ); + + GeneratedColumn get hasAttachments => $composableBuilder( + column: $table.hasAttachments, + builder: (column) => column, + ); GeneratedColumn get providerMetadataJson => $composableBuilder( column: $table.providerMetadataJson, builder: (column) => column, ); + GeneratedColumn get deleted => + $composableBuilder(column: $table.deleted, builder: (column) => column); + + GeneratedColumn get hidden => + $composableBuilder(column: $table.hidden, builder: (column) => column); + + GeneratedColumn get linksJson => + $composableBuilder(column: $table.linksJson, builder: (column) => column); + + GeneratedColumn get webViewLink => $composableBuilder( + column: $table.webViewLink, + builder: (column) => column, + ); + + GeneratedColumn get assignmentInfoJson => $composableBuilder( + column: $table.assignmentInfoJson, + builder: (column) => column, + ); + + GeneratedColumn get rawJson => + $composableBuilder(column: $table.rawJson, builder: (column) => column); + GeneratedColumn get serverMissing => $composableBuilder( column: $table.serverMissing, builder: (column) => column, @@ -30046,6 +33931,21 @@ class $$TaskListsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get pendingMove => $composableBuilder( + column: $table.pendingMove, + builder: (column) => column, + ); + + GeneratedColumn get localCreated => $composableBuilder( + column: $table.localCreated, + builder: (column) => column, + ); + + GeneratedColumn get syncBaseUpdatedUtc => $composableBuilder( + column: $table.syncBaseUpdatedUtc, + builder: (column) => column, + ); + GeneratedColumn get lastSyncedAtUtc => $composableBuilder( column: $table.lastSyncedAtUtc, builder: (column) => column, @@ -30106,75 +34006,222 @@ class $$TaskListsTableAnnotationComposer ); return composer; } + + $$DavObjectsTableAnnotationComposer get davObjectId { + final $$DavObjectsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davObjectId, + referencedTable: $db.davObjects, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectsTableAnnotationComposer( + $db: $db, + $table: $db.davObjects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$DavObjectComponentsTableAnnotationComposer get davComponentId { + final $$DavObjectComponentsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davComponentId, + referencedTable: $db.davObjectComponents, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectComponentsTableAnnotationComposer( + $db: $db, + $table: $db.davObjectComponents, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } } -class $$TaskListsTableTableManager +class $$TasksTableTableManager extends RootTableManager< _$AppDatabase, - $TaskListsTable, - TaskList, - $$TaskListsTableFilterComposer, - $$TaskListsTableOrderingComposer, - $$TaskListsTableAnnotationComposer, - $$TaskListsTableCreateCompanionBuilder, - $$TaskListsTableUpdateCompanionBuilder, - (TaskList, $$TaskListsTableReferences), - TaskList, - PrefetchHooks Function({bool accountId, bool davCollectionId}) + $TasksTable, + Task, + $$TasksTableFilterComposer, + $$TasksTableOrderingComposer, + $$TasksTableAnnotationComposer, + $$TasksTableCreateCompanionBuilder, + $$TasksTableUpdateCompanionBuilder, + (Task, $$TasksTableReferences), + Task, + PrefetchHooks Function({ + bool accountId, + bool davCollectionId, + bool davObjectId, + bool davComponentId, + }) > { - $$TaskListsTableTableManager(_$AppDatabase db, $TaskListsTable table) + $$TasksTableTableManager(_$AppDatabase db, $TasksTable table) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$TaskListsTableFilterComposer($db: db, $table: table), + $$TasksTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$TaskListsTableOrderingComposer($db: db, $table: table), + $$TasksTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$TaskListsTableAnnotationComposer($db: db, $table: table), + $$TasksTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value accountId = const Value.absent(), + Value taskListId = const Value.absent(), Value id = const Value.absent(), Value davCollectionId = const Value.absent(), + Value davObjectId = const Value.absent(), + Value davComponentId = const Value.absent(), + Value icalUid = const Value.absent(), + Value recurrenceIdKey = const Value.absent(), + Value icalPriority = const Value.absent(), + Value percentComplete = const Value.absent(), + Value taskLocation = const Value.absent(), + Value taskUrl = const Value.absent(), + Value taskClassification = const Value.absent(), + Value taskPinned = const Value.absent(), + Value taskHideSubtasks = const Value.absent(), + Value taskHideCompletedSubtasks = const Value.absent(), + Value taskAlarmsJson = const Value.absent(), + Value parentUid = const Value.absent(), + Value sortOrder = const Value.absent(), + Value providerExtensionProjectionJson = + const Value.absent(), + Value projectionVersion = const Value.absent(), Value kind = const Value.absent(), Value etag = const Value.absent(), Value title = const Value.absent(), Value updatedUtc = const Value.absent(), Value selfLink = const Value.absent(), - Value rawJson = const Value.absent(), - Value providerListKind = const Value.absent(), - Value isOwner = const Value.absent(), - Value isShared = const Value.absent(), - Value deltaLink = const Value.absent(), + Value parent = const Value.absent(), + Value position = const Value.absent(), + Value notes = const Value.absent(), + Value status = const Value.absent(), + Value dueUtc = const Value.absent(), + Value completedUtc = const Value.absent(), + Value providerStatus = const Value.absent(), + Value bodyContent = const Value.absent(), + Value bodyContentType = const Value.absent(), + Value microsoftDueDateTime = const Value.absent(), + Value microsoftDueTimeZone = const Value.absent(), + Value microsoftStartDateTime = const Value.absent(), + Value microsoftStartTimeZone = const Value.absent(), + Value microsoftReminderDateTime = const Value.absent(), + Value microsoftReminderTimeZone = const Value.absent(), + Value microsoftIsReminderOn = const Value.absent(), + Value microsoftCompletedDateTime = + const Value.absent(), + Value microsoftCompletedTimeZone = + const Value.absent(), + Value microsoftChecklistItemsJson = + const Value.absent(), + Value recurrenceJson = const Value.absent(), + Value importance = const Value.absent(), + Value categoriesJson = const Value.absent(), + Value hasAttachments = const Value.absent(), Value providerMetadataJson = const Value.absent(), + Value deleted = const Value.absent(), + Value hidden = const Value.absent(), + Value linksJson = const Value.absent(), + Value webViewLink = const Value.absent(), + Value assignmentInfoJson = const Value.absent(), + Value rawJson = const Value.absent(), Value serverMissing = const Value.absent(), Value localDirty = const Value.absent(), Value pendingDelete = const Value.absent(), + Value pendingMove = const Value.absent(), + Value localCreated = const Value.absent(), + Value syncBaseUpdatedUtc = const Value.absent(), Value lastSyncedAtUtc = const Value.absent(), Value createdLocalAtUtc = const Value.absent(), Value updatedLocalAtUtc = const Value.absent(), Value rowid = const Value.absent(), - }) => TaskListsCompanion( + }) => TasksCompanion( accountId: accountId, + taskListId: taskListId, id: id, davCollectionId: davCollectionId, + davObjectId: davObjectId, + davComponentId: davComponentId, + icalUid: icalUid, + recurrenceIdKey: recurrenceIdKey, + icalPriority: icalPriority, + percentComplete: percentComplete, + taskLocation: taskLocation, + taskUrl: taskUrl, + taskClassification: taskClassification, + taskPinned: taskPinned, + taskHideSubtasks: taskHideSubtasks, + taskHideCompletedSubtasks: taskHideCompletedSubtasks, + taskAlarmsJson: taskAlarmsJson, + parentUid: parentUid, + sortOrder: sortOrder, + providerExtensionProjectionJson: + providerExtensionProjectionJson, + projectionVersion: projectionVersion, kind: kind, etag: etag, title: title, updatedUtc: updatedUtc, selfLink: selfLink, - rawJson: rawJson, - providerListKind: providerListKind, - isOwner: isOwner, - isShared: isShared, - deltaLink: deltaLink, + parent: parent, + position: position, + notes: notes, + status: status, + dueUtc: dueUtc, + completedUtc: completedUtc, + providerStatus: providerStatus, + bodyContent: bodyContent, + bodyContentType: bodyContentType, + microsoftDueDateTime: microsoftDueDateTime, + microsoftDueTimeZone: microsoftDueTimeZone, + microsoftStartDateTime: microsoftStartDateTime, + microsoftStartTimeZone: microsoftStartTimeZone, + microsoftReminderDateTime: microsoftReminderDateTime, + microsoftReminderTimeZone: microsoftReminderTimeZone, + microsoftIsReminderOn: microsoftIsReminderOn, + microsoftCompletedDateTime: microsoftCompletedDateTime, + microsoftCompletedTimeZone: microsoftCompletedTimeZone, + microsoftChecklistItemsJson: microsoftChecklistItemsJson, + recurrenceJson: recurrenceJson, + importance: importance, + categoriesJson: categoriesJson, + hasAttachments: hasAttachments, providerMetadataJson: providerMetadataJson, + deleted: deleted, + hidden: hidden, + linksJson: linksJson, + webViewLink: webViewLink, + assignmentInfoJson: assignmentInfoJson, + rawJson: rawJson, serverMissing: serverMissing, localDirty: localDirty, pendingDelete: pendingDelete, + pendingMove: pendingMove, + localCreated: localCreated, + syncBaseUpdatedUtc: syncBaseUpdatedUtc, lastSyncedAtUtc: lastSyncedAtUtc, createdLocalAtUtc: createdLocalAtUtc, updatedLocalAtUtc: updatedLocalAtUtc, @@ -30183,44 +34230,139 @@ class $$TaskListsTableTableManager createCompanionCallback: ({ required String accountId, + required String taskListId, required String id, Value davCollectionId = const Value.absent(), + Value davObjectId = const Value.absent(), + Value davComponentId = const Value.absent(), + Value icalUid = const Value.absent(), + Value recurrenceIdKey = const Value.absent(), + Value icalPriority = const Value.absent(), + Value percentComplete = const Value.absent(), + Value taskLocation = const Value.absent(), + Value taskUrl = const Value.absent(), + Value taskClassification = const Value.absent(), + Value taskPinned = const Value.absent(), + Value taskHideSubtasks = const Value.absent(), + Value taskHideCompletedSubtasks = const Value.absent(), + Value taskAlarmsJson = const Value.absent(), + Value parentUid = const Value.absent(), + Value sortOrder = const Value.absent(), + Value providerExtensionProjectionJson = + const Value.absent(), + Value projectionVersion = const Value.absent(), Value kind = const Value.absent(), Value etag = const Value.absent(), required String title, Value updatedUtc = const Value.absent(), Value selfLink = const Value.absent(), - required String rawJson, - Value providerListKind = const Value.absent(), - Value isOwner = const Value.absent(), - Value isShared = const Value.absent(), - Value deltaLink = const Value.absent(), + Value parent = const Value.absent(), + Value position = const Value.absent(), + Value notes = const Value.absent(), + Value status = const Value.absent(), + Value dueUtc = const Value.absent(), + Value completedUtc = const Value.absent(), + Value providerStatus = const Value.absent(), + Value bodyContent = const Value.absent(), + Value bodyContentType = const Value.absent(), + Value microsoftDueDateTime = const Value.absent(), + Value microsoftDueTimeZone = const Value.absent(), + Value microsoftStartDateTime = const Value.absent(), + Value microsoftStartTimeZone = const Value.absent(), + Value microsoftReminderDateTime = const Value.absent(), + Value microsoftReminderTimeZone = const Value.absent(), + Value microsoftIsReminderOn = const Value.absent(), + Value microsoftCompletedDateTime = + const Value.absent(), + Value microsoftCompletedTimeZone = + const Value.absent(), + Value microsoftChecklistItemsJson = + const Value.absent(), + Value recurrenceJson = const Value.absent(), + Value importance = const Value.absent(), + Value categoriesJson = const Value.absent(), + Value hasAttachments = const Value.absent(), Value providerMetadataJson = const Value.absent(), + Value deleted = const Value.absent(), + Value hidden = const Value.absent(), + Value linksJson = const Value.absent(), + Value webViewLink = const Value.absent(), + Value assignmentInfoJson = const Value.absent(), + required String rawJson, Value serverMissing = const Value.absent(), Value localDirty = const Value.absent(), Value pendingDelete = const Value.absent(), + Value pendingMove = const Value.absent(), + Value localCreated = const Value.absent(), + Value syncBaseUpdatedUtc = const Value.absent(), Value lastSyncedAtUtc = const Value.absent(), required String createdLocalAtUtc, required String updatedLocalAtUtc, Value rowid = const Value.absent(), - }) => TaskListsCompanion.insert( + }) => TasksCompanion.insert( accountId: accountId, + taskListId: taskListId, id: id, davCollectionId: davCollectionId, + davObjectId: davObjectId, + davComponentId: davComponentId, + icalUid: icalUid, + recurrenceIdKey: recurrenceIdKey, + icalPriority: icalPriority, + percentComplete: percentComplete, + taskLocation: taskLocation, + taskUrl: taskUrl, + taskClassification: taskClassification, + taskPinned: taskPinned, + taskHideSubtasks: taskHideSubtasks, + taskHideCompletedSubtasks: taskHideCompletedSubtasks, + taskAlarmsJson: taskAlarmsJson, + parentUid: parentUid, + sortOrder: sortOrder, + providerExtensionProjectionJson: + providerExtensionProjectionJson, + projectionVersion: projectionVersion, kind: kind, etag: etag, title: title, updatedUtc: updatedUtc, selfLink: selfLink, - rawJson: rawJson, - providerListKind: providerListKind, - isOwner: isOwner, - isShared: isShared, - deltaLink: deltaLink, + parent: parent, + position: position, + notes: notes, + status: status, + dueUtc: dueUtc, + completedUtc: completedUtc, + providerStatus: providerStatus, + bodyContent: bodyContent, + bodyContentType: bodyContentType, + microsoftDueDateTime: microsoftDueDateTime, + microsoftDueTimeZone: microsoftDueTimeZone, + microsoftStartDateTime: microsoftStartDateTime, + microsoftStartTimeZone: microsoftStartTimeZone, + microsoftReminderDateTime: microsoftReminderDateTime, + microsoftReminderTimeZone: microsoftReminderTimeZone, + microsoftIsReminderOn: microsoftIsReminderOn, + microsoftCompletedDateTime: microsoftCompletedDateTime, + microsoftCompletedTimeZone: microsoftCompletedTimeZone, + microsoftChecklistItemsJson: microsoftChecklistItemsJson, + recurrenceJson: recurrenceJson, + importance: importance, + categoriesJson: categoriesJson, + hasAttachments: hasAttachments, providerMetadataJson: providerMetadataJson, + deleted: deleted, + hidden: hidden, + linksJson: linksJson, + webViewLink: webViewLink, + assignmentInfoJson: assignmentInfoJson, + rawJson: rawJson, serverMissing: serverMissing, localDirty: localDirty, pendingDelete: pendingDelete, + pendingMove: pendingMove, + localCreated: localCreated, + syncBaseUpdatedUtc: syncBaseUpdatedUtc, lastSyncedAtUtc: lastSyncedAtUtc, createdLocalAtUtc: createdLocalAtUtc, updatedLocalAtUtc: updatedLocalAtUtc, @@ -30228,14 +34370,17 @@ class $$TaskListsTableTableManager ), withReferenceMapper: (p0) => p0 .map( - (e) => ( - e.readTable(table), - $$TaskListsTableReferences(db, table, e), - ), + (e) => + (e.readTable(table), $$TasksTableReferences(db, table, e)), ) .toList(), prefetchHooksCallback: - ({accountId = false, davCollectionId = false}) { + ({ + accountId = false, + davCollectionId = false, + davObjectId = false, + davComponentId = false, + }) { return PrefetchHooks( db: db, explicitlyWatchedTables: [], @@ -30260,9 +34405,9 @@ class $$TaskListsTableTableManager state.withJoin( currentTable: table, currentColumn: table.accountId, - referencedTable: $$TaskListsTableReferences + referencedTable: $$TasksTableReferences ._accountIdTable(db), - referencedColumn: $$TaskListsTableReferences + referencedColumn: $$TasksTableReferences ._accountIdTable(db) .id, ) @@ -30273,14 +34418,40 @@ class $$TaskListsTableTableManager state.withJoin( currentTable: table, currentColumn: table.davCollectionId, - referencedTable: $$TaskListsTableReferences + referencedTable: $$TasksTableReferences ._davCollectionIdTable(db), - referencedColumn: $$TaskListsTableReferences + referencedColumn: $$TasksTableReferences ._davCollectionIdTable(db) .id, ) as T; } + if (davObjectId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.davObjectId, + referencedTable: $$TasksTableReferences + ._davObjectIdTable(db), + referencedColumn: $$TasksTableReferences + ._davObjectIdTable(db) + .id, + ) + as T; + } + if (davComponentId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.davComponentId, + referencedTable: $$TasksTableReferences + ._davComponentIdTable(db), + referencedColumn: $$TasksTableReferences + ._davComponentIdTable(db) + .id, + ) + as T; + } return state; }, @@ -30293,165 +34464,122 @@ class $$TaskListsTableTableManager ); } -typedef $$TaskListsTableProcessedTableManager = +typedef $$TasksTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $TaskListsTable, - TaskList, - $$TaskListsTableFilterComposer, - $$TaskListsTableOrderingComposer, - $$TaskListsTableAnnotationComposer, - $$TaskListsTableCreateCompanionBuilder, - $$TaskListsTableUpdateCompanionBuilder, - (TaskList, $$TaskListsTableReferences), - TaskList, - PrefetchHooks Function({bool accountId, bool davCollectionId}) + $TasksTable, + Task, + $$TasksTableFilterComposer, + $$TasksTableOrderingComposer, + $$TasksTableAnnotationComposer, + $$TasksTableCreateCompanionBuilder, + $$TasksTableUpdateCompanionBuilder, + (Task, $$TasksTableReferences), + Task, + PrefetchHooks Function({ + bool accountId, + bool davCollectionId, + bool davObjectId, + bool davComponentId, + }) >; -typedef $$TasksTableCreateCompanionBuilder = - TasksCompanion Function({ - required String accountId, - required String taskListId, +typedef $$PendingOpsTableCreateCompanionBuilder = + PendingOpsCompanion Function({ required String id, + required String accountId, + Value provider, + required String entityType, + required String operation, + Value operationType, + Value taskListId, + Value taskId, + Value calendarSourceId, + Value providerCalendarId, + Value eventId, Value davCollectionId, + Value davCollectionHref, Value davObjectId, - Value davComponentId, - Value icalUid, - Value recurrenceIdKey, - Value icalPriority, - Value percentComplete, - Value taskLocation, - Value taskUrl, - Value taskClassification, - Value taskPinned, - Value taskHideSubtasks, - Value taskHideCompletedSubtasks, - Value taskAlarmsJson, - Value parentUid, - Value sortOrder, - Value providerExtensionProjectionJson, - Value projectionVersion, - Value kind, - Value etag, - required String title, - Value updatedUtc, - Value selfLink, - Value parent, - Value position, - Value notes, - Value status, - Value dueUtc, - Value completedUtc, - Value providerStatus, - Value bodyContent, - Value bodyContentType, - Value microsoftDueDateTime, - Value microsoftDueTimeZone, - Value microsoftStartDateTime, - Value microsoftStartTimeZone, - Value microsoftReminderDateTime, - Value microsoftReminderTimeZone, - Value microsoftIsReminderOn, - Value microsoftCompletedDateTime, - Value microsoftCompletedTimeZone, - Value microsoftChecklistItemsJson, - Value recurrenceJson, - Value importance, - Value categoriesJson, - Value hasAttachments, - Value providerMetadataJson, - Value deleted, - Value hidden, - Value linksJson, - Value webViewLink, - Value assignmentInfoJson, - required String rawJson, - Value serverMissing, - Value localDirty, - Value pendingDelete, - Value pendingMove, - Value localCreated, - Value syncBaseUpdatedUtc, - Value lastSyncedAtUtc, - required String createdLocalAtUtc, - required String updatedLocalAtUtc, + Value davMemberHref, + Value baselineEtag, + Value baselineRawIcs, + Value mutationPatchJson, + Value mutationPatchSchemaVersion, + Value targetComponentKey, + Value mutationScope, + Value destinationCollectionId, + Value destinationCollectionHref, + Value destinationMemberHref, + Value conflictState, + Value conflictSnapshotId, + Value retryClassification, + Value localTempId, + Value dependsOnOpId, + required String requestJson, + Value baselineUpdatedUtc, + Value baselineRawJson, + Value attemptCount, + Value nextAttemptAtUtc, + Value lastErrorCode, + Value lastErrorMessage, + Value state, + Value lastError, + required String createdAtUtc, + required String updatedAtUtc, Value rowid, }); -typedef $$TasksTableUpdateCompanionBuilder = - TasksCompanion Function({ - Value accountId, - Value taskListId, +typedef $$PendingOpsTableUpdateCompanionBuilder = + PendingOpsCompanion Function({ Value id, + Value accountId, + Value provider, + Value entityType, + Value operation, + Value operationType, + Value taskListId, + Value taskId, + Value calendarSourceId, + Value providerCalendarId, + Value eventId, Value davCollectionId, + Value davCollectionHref, Value davObjectId, - Value davComponentId, - Value icalUid, - Value recurrenceIdKey, - Value icalPriority, - Value percentComplete, - Value taskLocation, - Value taskUrl, - Value taskClassification, - Value taskPinned, - Value taskHideSubtasks, - Value taskHideCompletedSubtasks, - Value taskAlarmsJson, - Value parentUid, - Value sortOrder, - Value providerExtensionProjectionJson, - Value projectionVersion, - Value kind, - Value etag, - Value title, - Value updatedUtc, - Value selfLink, - Value parent, - Value position, - Value notes, - Value status, - Value dueUtc, - Value completedUtc, - Value providerStatus, - Value bodyContent, - Value bodyContentType, - Value microsoftDueDateTime, - Value microsoftDueTimeZone, - Value microsoftStartDateTime, - Value microsoftStartTimeZone, - Value microsoftReminderDateTime, - Value microsoftReminderTimeZone, - Value microsoftIsReminderOn, - Value microsoftCompletedDateTime, - Value microsoftCompletedTimeZone, - Value microsoftChecklistItemsJson, - Value recurrenceJson, - Value importance, - Value categoriesJson, - Value hasAttachments, - Value providerMetadataJson, - Value deleted, - Value hidden, - Value linksJson, - Value webViewLink, - Value assignmentInfoJson, - Value rawJson, - Value serverMissing, - Value localDirty, - Value pendingDelete, - Value pendingMove, - Value localCreated, - Value syncBaseUpdatedUtc, - Value lastSyncedAtUtc, - Value createdLocalAtUtc, - Value updatedLocalAtUtc, + Value davMemberHref, + Value baselineEtag, + Value baselineRawIcs, + Value mutationPatchJson, + Value mutationPatchSchemaVersion, + Value targetComponentKey, + Value mutationScope, + Value destinationCollectionId, + Value destinationCollectionHref, + Value destinationMemberHref, + Value conflictState, + Value conflictSnapshotId, + Value retryClassification, + Value localTempId, + Value dependsOnOpId, + Value requestJson, + Value baselineUpdatedUtc, + Value baselineRawJson, + Value attemptCount, + Value nextAttemptAtUtc, + Value lastErrorCode, + Value lastErrorMessage, + Value state, + Value lastError, + Value createdAtUtc, + Value updatedAtUtc, Value rowid, }); -final class $$TasksTableReferences - extends BaseReferences<_$AppDatabase, $TasksTable, Task> { - $$TasksTableReferences(super.$_db, super.$_table, super.$_typedResult); +final class $$PendingOpsTableReferences + extends BaseReferences<_$AppDatabase, $PendingOpsTable, PendingOp> { + $$PendingOpsTableReferences(super.$_db, super.$_table, super.$_typedResult); - static $AccountsTable _accountIdTable(_$AppDatabase db) => db.accounts - .createAlias($_aliasNameGenerator(db.tasks.accountId, db.accounts.id)); + static $AccountsTable _accountIdTable(_$AppDatabase db) => + db.accounts.createAlias( + $_aliasNameGenerator(db.pendingOps.accountId, db.accounts.id), + ); $$AccountsTableProcessedTableManager get accountId { final $_column = $_itemColumn('account_id')!; @@ -30469,7 +34597,10 @@ final class $$TasksTableReferences static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => db.davCollections.createAlias( - $_aliasNameGenerator(db.tasks.davCollectionId, db.davCollections.id), + $_aliasNameGenerator( + db.pendingOps.davCollectionId, + db.davCollections.id, + ), ); $$DavCollectionsTableProcessedTableManager? get davCollectionId { @@ -30488,7 +34619,7 @@ final class $$TasksTableReferences static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => db.davObjects.createAlias( - $_aliasNameGenerator(db.tasks.davObjectId, db.davObjects.id), + $_aliasNameGenerator(db.pendingOps.davObjectId, db.davObjects.id), ); $$DavObjectsTableProcessedTableManager? get davObjectId { @@ -30505,22 +34636,47 @@ final class $$TasksTableReferences ); } - static $DavObjectComponentsTable _davComponentIdTable(_$AppDatabase db) => - db.davObjectComponents.createAlias( + static $DavCollectionsTable _destinationCollectionIdTable(_$AppDatabase db) => + db.davCollections.createAlias( $_aliasNameGenerator( - db.tasks.davComponentId, - db.davObjectComponents.id, + db.pendingOps.destinationCollectionId, + db.davCollections.id, ), ); - $$DavObjectComponentsTableProcessedTableManager? get davComponentId { - final $_column = $_itemColumn('dav_component_id'); + $$DavCollectionsTableProcessedTableManager? get destinationCollectionId { + final $_column = $_itemColumn('destination_collection_id'); + if ($_column == null) return null; + final manager = $$DavCollectionsTableTableManager( + $_db, + $_db.davCollections, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull( + _destinationCollectionIdTable($_db), + ); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $DavConflictSnapshotsTable _conflictSnapshotIdTable( + _$AppDatabase db, + ) => db.davConflictSnapshots.createAlias( + $_aliasNameGenerator( + db.pendingOps.conflictSnapshotId, + db.davConflictSnapshots.id, + ), + ); + + $$DavConflictSnapshotsTableProcessedTableManager? get conflictSnapshotId { + final $_column = $_itemColumn('conflict_snapshot_id'); if ($_column == null) return null; - final manager = $$DavObjectComponentsTableTableManager( + final manager = $$DavConflictSnapshotsTableTableManager( $_db, - $_db.davObjectComponents, + $_db.davConflictSnapshots, ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_davComponentIdTable($_db)); + final item = $_typedResult.readTableOrNull(_conflictSnapshotIdTable($_db)); if (item == null) return manager; return ProcessedTableManager( manager.$state.copyWith(prefetchedData: [item]), @@ -30528,317 +34684,187 @@ final class $$TasksTableReferences } } -class $$TasksTableFilterComposer extends Composer<_$AppDatabase, $TasksTable> { - $$TasksTableFilterComposer({ +class $$PendingOpsTableFilterComposer + extends Composer<_$AppDatabase, $PendingOpsTable> { + $$PendingOpsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get taskListId => $composableBuilder( - column: $table.taskListId, - builder: (column) => ColumnFilters(column), - ); - ColumnFilters get id => $composableBuilder( column: $table.id, builder: (column) => ColumnFilters(column), ); - ColumnFilters get icalUid => $composableBuilder( - column: $table.icalUid, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get recurrenceIdKey => $composableBuilder( - column: $table.recurrenceIdKey, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get icalPriority => $composableBuilder( - column: $table.icalPriority, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get percentComplete => $composableBuilder( - column: $table.percentComplete, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get taskLocation => $composableBuilder( - column: $table.taskLocation, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get taskUrl => $composableBuilder( - column: $table.taskUrl, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get taskClassification => $composableBuilder( - column: $table.taskClassification, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get taskPinned => $composableBuilder( - column: $table.taskPinned, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get taskHideSubtasks => $composableBuilder( - column: $table.taskHideSubtasks, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get taskHideCompletedSubtasks => $composableBuilder( - column: $table.taskHideCompletedSubtasks, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get taskAlarmsJson => $composableBuilder( - column: $table.taskAlarmsJson, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get parentUid => $composableBuilder( - column: $table.parentUid, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get sortOrder => $composableBuilder( - column: $table.sortOrder, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get providerExtensionProjectionJson => - $composableBuilder( - column: $table.providerExtensionProjectionJson, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get projectionVersion => $composableBuilder( - column: $table.projectionVersion, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get kind => $composableBuilder( - column: $table.kind, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get etag => $composableBuilder( - column: $table.etag, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get title => $composableBuilder( - column: $table.title, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get updatedUtc => $composableBuilder( - column: $table.updatedUtc, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get selfLink => $composableBuilder( - column: $table.selfLink, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get parent => $composableBuilder( - column: $table.parent, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get position => $composableBuilder( - column: $table.position, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get notes => $composableBuilder( - column: $table.notes, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get status => $composableBuilder( - column: $table.status, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get dueUtc => $composableBuilder( - column: $table.dueUtc, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get completedUtc => $composableBuilder( - column: $table.completedUtc, + ColumnFilters get provider => $composableBuilder( + column: $table.provider, builder: (column) => ColumnFilters(column), ); - ColumnFilters get providerStatus => $composableBuilder( - column: $table.providerStatus, + ColumnFilters get entityType => $composableBuilder( + column: $table.entityType, builder: (column) => ColumnFilters(column), ); - ColumnFilters get bodyContent => $composableBuilder( - column: $table.bodyContent, + ColumnFilters get operation => $composableBuilder( + column: $table.operation, builder: (column) => ColumnFilters(column), ); - ColumnFilters get bodyContentType => $composableBuilder( - column: $table.bodyContentType, + ColumnFilters get operationType => $composableBuilder( + column: $table.operationType, builder: (column) => ColumnFilters(column), ); - ColumnFilters get microsoftDueDateTime => $composableBuilder( - column: $table.microsoftDueDateTime, + ColumnFilters get taskListId => $composableBuilder( + column: $table.taskListId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get microsoftDueTimeZone => $composableBuilder( - column: $table.microsoftDueTimeZone, + ColumnFilters get taskId => $composableBuilder( + column: $table.taskId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get microsoftStartDateTime => $composableBuilder( - column: $table.microsoftStartDateTime, + ColumnFilters get calendarSourceId => $composableBuilder( + column: $table.calendarSourceId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get microsoftStartTimeZone => $composableBuilder( - column: $table.microsoftStartTimeZone, + ColumnFilters get providerCalendarId => $composableBuilder( + column: $table.providerCalendarId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get microsoftReminderDateTime => $composableBuilder( - column: $table.microsoftReminderDateTime, + ColumnFilters get eventId => $composableBuilder( + column: $table.eventId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get microsoftReminderTimeZone => $composableBuilder( - column: $table.microsoftReminderTimeZone, + ColumnFilters get davCollectionHref => $composableBuilder( + column: $table.davCollectionHref, builder: (column) => ColumnFilters(column), ); - ColumnFilters get microsoftIsReminderOn => $composableBuilder( - column: $table.microsoftIsReminderOn, + ColumnFilters get davMemberHref => $composableBuilder( + column: $table.davMemberHref, builder: (column) => ColumnFilters(column), ); - ColumnFilters get microsoftCompletedDateTime => $composableBuilder( - column: $table.microsoftCompletedDateTime, + ColumnFilters get baselineEtag => $composableBuilder( + column: $table.baselineEtag, builder: (column) => ColumnFilters(column), ); - ColumnFilters get microsoftCompletedTimeZone => $composableBuilder( - column: $table.microsoftCompletedTimeZone, + ColumnFilters get baselineRawIcs => $composableBuilder( + column: $table.baselineRawIcs, builder: (column) => ColumnFilters(column), ); - ColumnFilters get microsoftChecklistItemsJson => $composableBuilder( - column: $table.microsoftChecklistItemsJson, + ColumnFilters get mutationPatchJson => $composableBuilder( + column: $table.mutationPatchJson, builder: (column) => ColumnFilters(column), ); - ColumnFilters get recurrenceJson => $composableBuilder( - column: $table.recurrenceJson, + ColumnFilters get mutationPatchSchemaVersion => $composableBuilder( + column: $table.mutationPatchSchemaVersion, builder: (column) => ColumnFilters(column), ); - ColumnFilters get importance => $composableBuilder( - column: $table.importance, + ColumnFilters get targetComponentKey => $composableBuilder( + column: $table.targetComponentKey, builder: (column) => ColumnFilters(column), ); - ColumnFilters get categoriesJson => $composableBuilder( - column: $table.categoriesJson, + ColumnFilters get mutationScope => $composableBuilder( + column: $table.mutationScope, builder: (column) => ColumnFilters(column), ); - ColumnFilters get hasAttachments => $composableBuilder( - column: $table.hasAttachments, + ColumnFilters get destinationCollectionHref => $composableBuilder( + column: $table.destinationCollectionHref, builder: (column) => ColumnFilters(column), ); - ColumnFilters get providerMetadataJson => $composableBuilder( - column: $table.providerMetadataJson, + ColumnFilters get destinationMemberHref => $composableBuilder( + column: $table.destinationMemberHref, builder: (column) => ColumnFilters(column), ); - ColumnFilters get deleted => $composableBuilder( - column: $table.deleted, + ColumnFilters get conflictState => $composableBuilder( + column: $table.conflictState, builder: (column) => ColumnFilters(column), ); - ColumnFilters get hidden => $composableBuilder( - column: $table.hidden, + ColumnFilters get retryClassification => $composableBuilder( + column: $table.retryClassification, builder: (column) => ColumnFilters(column), ); - ColumnFilters get linksJson => $composableBuilder( - column: $table.linksJson, + ColumnFilters get localTempId => $composableBuilder( + column: $table.localTempId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get webViewLink => $composableBuilder( - column: $table.webViewLink, + ColumnFilters get dependsOnOpId => $composableBuilder( + column: $table.dependsOnOpId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get assignmentInfoJson => $composableBuilder( - column: $table.assignmentInfoJson, + ColumnFilters get requestJson => $composableBuilder( + column: $table.requestJson, builder: (column) => ColumnFilters(column), ); - ColumnFilters get rawJson => $composableBuilder( - column: $table.rawJson, + ColumnFilters get baselineUpdatedUtc => $composableBuilder( + column: $table.baselineUpdatedUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get serverMissing => $composableBuilder( - column: $table.serverMissing, + ColumnFilters get baselineRawJson => $composableBuilder( + column: $table.baselineRawJson, builder: (column) => ColumnFilters(column), ); - ColumnFilters get localDirty => $composableBuilder( - column: $table.localDirty, + ColumnFilters get attemptCount => $composableBuilder( + column: $table.attemptCount, builder: (column) => ColumnFilters(column), ); - ColumnFilters get pendingDelete => $composableBuilder( - column: $table.pendingDelete, + ColumnFilters get nextAttemptAtUtc => $composableBuilder( + column: $table.nextAttemptAtUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get pendingMove => $composableBuilder( - column: $table.pendingMove, + ColumnFilters get lastErrorCode => $composableBuilder( + column: $table.lastErrorCode, builder: (column) => ColumnFilters(column), ); - ColumnFilters get localCreated => $composableBuilder( - column: $table.localCreated, + ColumnFilters get lastErrorMessage => $composableBuilder( + column: $table.lastErrorMessage, builder: (column) => ColumnFilters(column), ); - ColumnFilters get syncBaseUpdatedUtc => $composableBuilder( - column: $table.syncBaseUpdatedUtc, + ColumnFilters get state => $composableBuilder( + column: $table.state, builder: (column) => ColumnFilters(column), ); - ColumnFilters get lastSyncedAtUtc => $composableBuilder( - column: $table.lastSyncedAtUtc, + ColumnFilters get lastError => $composableBuilder( + column: $table.lastError, builder: (column) => ColumnFilters(column), ); - ColumnFilters get createdLocalAtUtc => $composableBuilder( - column: $table.createdLocalAtUtc, + ColumnFilters get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get updatedLocalAtUtc => $composableBuilder( - column: $table.updatedLocalAtUtc, + ColumnFilters get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, builder: (column) => ColumnFilters(column), ); @@ -30911,20 +34937,43 @@ class $$TasksTableFilterComposer extends Composer<_$AppDatabase, $TasksTable> { return composer; } - $$DavObjectComponentsTableFilterComposer get davComponentId { - final $$DavObjectComponentsTableFilterComposer composer = $composerBuilder( + $$DavCollectionsTableFilterComposer get destinationCollectionId { + final $$DavCollectionsTableFilterComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.davComponentId, - referencedTable: $db.davObjectComponents, + getCurrentColumn: (t) => t.destinationCollectionId, + referencedTable: $db.davCollections, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavObjectComponentsTableFilterComposer( + }) => $$DavCollectionsTableFilterComposer( $db: $db, - $table: $db.davObjectComponents, + $table: $db.davCollections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$DavConflictSnapshotsTableFilterComposer get conflictSnapshotId { + final $$DavConflictSnapshotsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.conflictSnapshotId, + referencedTable: $db.davConflictSnapshots, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavConflictSnapshotsTableFilterComposer( + $db: $db, + $table: $db.davConflictSnapshots, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -30935,318 +34984,187 @@ class $$TasksTableFilterComposer extends Composer<_$AppDatabase, $TasksTable> { } } -class $$TasksTableOrderingComposer - extends Composer<_$AppDatabase, $TasksTable> { - $$TasksTableOrderingComposer({ +class $$PendingOpsTableOrderingComposer + extends Composer<_$AppDatabase, $PendingOpsTable> { + $$PendingOpsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get taskListId => $composableBuilder( - column: $table.taskListId, - builder: (column) => ColumnOrderings(column), - ); - ColumnOrderings get id => $composableBuilder( column: $table.id, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get icalUid => $composableBuilder( - column: $table.icalUid, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get recurrenceIdKey => $composableBuilder( - column: $table.recurrenceIdKey, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get icalPriority => $composableBuilder( - column: $table.icalPriority, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get percentComplete => $composableBuilder( - column: $table.percentComplete, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get taskLocation => $composableBuilder( - column: $table.taskLocation, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get taskUrl => $composableBuilder( - column: $table.taskUrl, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get taskClassification => $composableBuilder( - column: $table.taskClassification, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get taskPinned => $composableBuilder( - column: $table.taskPinned, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get taskHideSubtasks => $composableBuilder( - column: $table.taskHideSubtasks, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get taskHideCompletedSubtasks => $composableBuilder( - column: $table.taskHideCompletedSubtasks, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get taskAlarmsJson => $composableBuilder( - column: $table.taskAlarmsJson, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get parentUid => $composableBuilder( - column: $table.parentUid, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get sortOrder => $composableBuilder( - column: $table.sortOrder, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get providerExtensionProjectionJson => - $composableBuilder( - column: $table.providerExtensionProjectionJson, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get projectionVersion => $composableBuilder( - column: $table.projectionVersion, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get kind => $composableBuilder( - column: $table.kind, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get etag => $composableBuilder( - column: $table.etag, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get title => $composableBuilder( - column: $table.title, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get updatedUtc => $composableBuilder( - column: $table.updatedUtc, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get selfLink => $composableBuilder( - column: $table.selfLink, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get parent => $composableBuilder( - column: $table.parent, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get position => $composableBuilder( - column: $table.position, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get notes => $composableBuilder( - column: $table.notes, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get status => $composableBuilder( - column: $table.status, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get dueUtc => $composableBuilder( - column: $table.dueUtc, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get completedUtc => $composableBuilder( - column: $table.completedUtc, + ColumnOrderings get provider => $composableBuilder( + column: $table.provider, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get providerStatus => $composableBuilder( - column: $table.providerStatus, + ColumnOrderings get entityType => $composableBuilder( + column: $table.entityType, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get bodyContent => $composableBuilder( - column: $table.bodyContent, + ColumnOrderings get operation => $composableBuilder( + column: $table.operation, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get bodyContentType => $composableBuilder( - column: $table.bodyContentType, + ColumnOrderings get operationType => $composableBuilder( + column: $table.operationType, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get microsoftDueDateTime => $composableBuilder( - column: $table.microsoftDueDateTime, + ColumnOrderings get taskListId => $composableBuilder( + column: $table.taskListId, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get microsoftDueTimeZone => $composableBuilder( - column: $table.microsoftDueTimeZone, + ColumnOrderings get taskId => $composableBuilder( + column: $table.taskId, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get microsoftStartDateTime => $composableBuilder( - column: $table.microsoftStartDateTime, + ColumnOrderings get calendarSourceId => $composableBuilder( + column: $table.calendarSourceId, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get microsoftStartTimeZone => $composableBuilder( - column: $table.microsoftStartTimeZone, + ColumnOrderings get providerCalendarId => $composableBuilder( + column: $table.providerCalendarId, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get microsoftReminderDateTime => $composableBuilder( - column: $table.microsoftReminderDateTime, + ColumnOrderings get eventId => $composableBuilder( + column: $table.eventId, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get microsoftReminderTimeZone => $composableBuilder( - column: $table.microsoftReminderTimeZone, + ColumnOrderings get davCollectionHref => $composableBuilder( + column: $table.davCollectionHref, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get microsoftIsReminderOn => $composableBuilder( - column: $table.microsoftIsReminderOn, + ColumnOrderings get davMemberHref => $composableBuilder( + column: $table.davMemberHref, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get microsoftCompletedDateTime => $composableBuilder( - column: $table.microsoftCompletedDateTime, + ColumnOrderings get baselineEtag => $composableBuilder( + column: $table.baselineEtag, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get microsoftCompletedTimeZone => $composableBuilder( - column: $table.microsoftCompletedTimeZone, + ColumnOrderings get baselineRawIcs => $composableBuilder( + column: $table.baselineRawIcs, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get microsoftChecklistItemsJson => $composableBuilder( - column: $table.microsoftChecklistItemsJson, + ColumnOrderings get mutationPatchJson => $composableBuilder( + column: $table.mutationPatchJson, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get recurrenceJson => $composableBuilder( - column: $table.recurrenceJson, + ColumnOrderings get mutationPatchSchemaVersion => $composableBuilder( + column: $table.mutationPatchSchemaVersion, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get importance => $composableBuilder( - column: $table.importance, + ColumnOrderings get targetComponentKey => $composableBuilder( + column: $table.targetComponentKey, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get categoriesJson => $composableBuilder( - column: $table.categoriesJson, + ColumnOrderings get mutationScope => $composableBuilder( + column: $table.mutationScope, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get hasAttachments => $composableBuilder( - column: $table.hasAttachments, + ColumnOrderings get destinationCollectionHref => $composableBuilder( + column: $table.destinationCollectionHref, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get providerMetadataJson => $composableBuilder( - column: $table.providerMetadataJson, + ColumnOrderings get destinationMemberHref => $composableBuilder( + column: $table.destinationMemberHref, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get deleted => $composableBuilder( - column: $table.deleted, + ColumnOrderings get conflictState => $composableBuilder( + column: $table.conflictState, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get hidden => $composableBuilder( - column: $table.hidden, + ColumnOrderings get retryClassification => $composableBuilder( + column: $table.retryClassification, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get linksJson => $composableBuilder( - column: $table.linksJson, + ColumnOrderings get localTempId => $composableBuilder( + column: $table.localTempId, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get webViewLink => $composableBuilder( - column: $table.webViewLink, + ColumnOrderings get dependsOnOpId => $composableBuilder( + column: $table.dependsOnOpId, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get assignmentInfoJson => $composableBuilder( - column: $table.assignmentInfoJson, + ColumnOrderings get requestJson => $composableBuilder( + column: $table.requestJson, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get rawJson => $composableBuilder( - column: $table.rawJson, + ColumnOrderings get baselineUpdatedUtc => $composableBuilder( + column: $table.baselineUpdatedUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get serverMissing => $composableBuilder( - column: $table.serverMissing, + ColumnOrderings get baselineRawJson => $composableBuilder( + column: $table.baselineRawJson, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get localDirty => $composableBuilder( - column: $table.localDirty, + ColumnOrderings get attemptCount => $composableBuilder( + column: $table.attemptCount, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get pendingDelete => $composableBuilder( - column: $table.pendingDelete, + ColumnOrderings get nextAttemptAtUtc => $composableBuilder( + column: $table.nextAttemptAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get pendingMove => $composableBuilder( - column: $table.pendingMove, + ColumnOrderings get lastErrorCode => $composableBuilder( + column: $table.lastErrorCode, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get localCreated => $composableBuilder( - column: $table.localCreated, + ColumnOrderings get lastErrorMessage => $composableBuilder( + column: $table.lastErrorMessage, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get syncBaseUpdatedUtc => $composableBuilder( - column: $table.syncBaseUpdatedUtc, + ColumnOrderings get state => $composableBuilder( + column: $table.state, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get lastSyncedAtUtc => $composableBuilder( - column: $table.lastSyncedAtUtc, + ColumnOrderings get lastError => $composableBuilder( + column: $table.lastError, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get createdLocalAtUtc => $composableBuilder( - column: $table.createdLocalAtUtc, + ColumnOrderings get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get updatedLocalAtUtc => $composableBuilder( - column: $table.updatedLocalAtUtc, + ColumnOrderings get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, builder: (column) => ColumnOrderings(column), ); @@ -31319,21 +35237,44 @@ class $$TasksTableOrderingComposer return composer; } - $$DavObjectComponentsTableOrderingComposer get davComponentId { - final $$DavObjectComponentsTableOrderingComposer composer = + $$DavCollectionsTableOrderingComposer get destinationCollectionId { + final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.destinationCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavCollectionsTableOrderingComposer( + $db: $db, + $table: $db.davCollections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$DavConflictSnapshotsTableOrderingComposer get conflictSnapshotId { + final $$DavConflictSnapshotsTableOrderingComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.davComponentId, - referencedTable: $db.davObjectComponents, + getCurrentColumn: (t) => t.conflictSnapshotId, + referencedTable: $db.davConflictSnapshots, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavObjectComponentsTableOrderingComposer( + }) => $$DavConflictSnapshotsTableOrderingComposer( $db: $db, - $table: $db.davObjectComponents, + $table: $db.davConflictSnapshots, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -31344,282 +35285,173 @@ class $$TasksTableOrderingComposer } } -class $$TasksTableAnnotationComposer - extends Composer<_$AppDatabase, $TasksTable> { - $$TasksTableAnnotationComposer({ +class $$PendingOpsTableAnnotationComposer + extends Composer<_$AppDatabase, $PendingOpsTable> { + $$PendingOpsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get taskListId => $composableBuilder( - column: $table.taskListId, - builder: (column) => column, - ); - GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get icalUid => - $composableBuilder(column: $table.icalUid, builder: (column) => column); - - GeneratedColumn get recurrenceIdKey => $composableBuilder( - column: $table.recurrenceIdKey, - builder: (column) => column, - ); - - GeneratedColumn get icalPriority => $composableBuilder( - column: $table.icalPriority, - builder: (column) => column, - ); - - GeneratedColumn get percentComplete => $composableBuilder( - column: $table.percentComplete, - builder: (column) => column, - ); - - GeneratedColumn get taskLocation => $composableBuilder( - column: $table.taskLocation, - builder: (column) => column, - ); - - GeneratedColumn get taskUrl => - $composableBuilder(column: $table.taskUrl, builder: (column) => column); - - GeneratedColumn get taskClassification => $composableBuilder( - column: $table.taskClassification, - builder: (column) => column, - ); - - GeneratedColumn get taskPinned => $composableBuilder( - column: $table.taskPinned, - builder: (column) => column, - ); - - GeneratedColumn get taskHideSubtasks => $composableBuilder( - column: $table.taskHideSubtasks, - builder: (column) => column, - ); - - GeneratedColumn get taskHideCompletedSubtasks => $composableBuilder( - column: $table.taskHideCompletedSubtasks, - builder: (column) => column, - ); - - GeneratedColumn get taskAlarmsJson => $composableBuilder( - column: $table.taskAlarmsJson, - builder: (column) => column, - ); - - GeneratedColumn get parentUid => - $composableBuilder(column: $table.parentUid, builder: (column) => column); - - GeneratedColumn get sortOrder => - $composableBuilder(column: $table.sortOrder, builder: (column) => column); - - GeneratedColumn get providerExtensionProjectionJson => - $composableBuilder( - column: $table.providerExtensionProjectionJson, - builder: (column) => column, - ); + GeneratedColumn get provider => + $composableBuilder(column: $table.provider, builder: (column) => column); - GeneratedColumn get projectionVersion => $composableBuilder( - column: $table.projectionVersion, + GeneratedColumn get entityType => $composableBuilder( + column: $table.entityType, builder: (column) => column, ); - GeneratedColumn get kind => - $composableBuilder(column: $table.kind, builder: (column) => column); - - GeneratedColumn get etag => - $composableBuilder(column: $table.etag, builder: (column) => column); - - GeneratedColumn get title => - $composableBuilder(column: $table.title, builder: (column) => column); + GeneratedColumn get operation => + $composableBuilder(column: $table.operation, builder: (column) => column); - GeneratedColumn get updatedUtc => $composableBuilder( - column: $table.updatedUtc, + GeneratedColumn get operationType => $composableBuilder( + column: $table.operationType, builder: (column) => column, ); - GeneratedColumn get selfLink => - $composableBuilder(column: $table.selfLink, builder: (column) => column); - - GeneratedColumn get parent => - $composableBuilder(column: $table.parent, builder: (column) => column); - - GeneratedColumn get position => - $composableBuilder(column: $table.position, builder: (column) => column); - - GeneratedColumn get notes => - $composableBuilder(column: $table.notes, builder: (column) => column); - - GeneratedColumn get status => - $composableBuilder(column: $table.status, builder: (column) => column); - - GeneratedColumn get dueUtc => - $composableBuilder(column: $table.dueUtc, builder: (column) => column); - - GeneratedColumn get completedUtc => $composableBuilder( - column: $table.completedUtc, + GeneratedColumn get taskListId => $composableBuilder( + column: $table.taskListId, builder: (column) => column, ); - GeneratedColumn get providerStatus => $composableBuilder( - column: $table.providerStatus, - builder: (column) => column, - ); + GeneratedColumn get taskId => + $composableBuilder(column: $table.taskId, builder: (column) => column); - GeneratedColumn get bodyContent => $composableBuilder( - column: $table.bodyContent, + GeneratedColumn get calendarSourceId => $composableBuilder( + column: $table.calendarSourceId, builder: (column) => column, ); - GeneratedColumn get bodyContentType => $composableBuilder( - column: $table.bodyContentType, + GeneratedColumn get providerCalendarId => $composableBuilder( + column: $table.providerCalendarId, builder: (column) => column, ); - GeneratedColumn get microsoftDueDateTime => $composableBuilder( - column: $table.microsoftDueDateTime, - builder: (column) => column, - ); + GeneratedColumn get eventId => + $composableBuilder(column: $table.eventId, builder: (column) => column); - GeneratedColumn get microsoftDueTimeZone => $composableBuilder( - column: $table.microsoftDueTimeZone, + GeneratedColumn get davCollectionHref => $composableBuilder( + column: $table.davCollectionHref, builder: (column) => column, ); - GeneratedColumn get microsoftStartDateTime => $composableBuilder( - column: $table.microsoftStartDateTime, + GeneratedColumn get davMemberHref => $composableBuilder( + column: $table.davMemberHref, builder: (column) => column, ); - GeneratedColumn get microsoftStartTimeZone => $composableBuilder( - column: $table.microsoftStartTimeZone, + GeneratedColumn get baselineEtag => $composableBuilder( + column: $table.baselineEtag, builder: (column) => column, ); - GeneratedColumn get microsoftReminderDateTime => $composableBuilder( - column: $table.microsoftReminderDateTime, + GeneratedColumn get baselineRawIcs => $composableBuilder( + column: $table.baselineRawIcs, builder: (column) => column, ); - GeneratedColumn get microsoftReminderTimeZone => $composableBuilder( - column: $table.microsoftReminderTimeZone, + GeneratedColumn get mutationPatchJson => $composableBuilder( + column: $table.mutationPatchJson, builder: (column) => column, ); - GeneratedColumn get microsoftIsReminderOn => $composableBuilder( - column: $table.microsoftIsReminderOn, + GeneratedColumn get mutationPatchSchemaVersion => $composableBuilder( + column: $table.mutationPatchSchemaVersion, builder: (column) => column, ); - GeneratedColumn get microsoftCompletedDateTime => $composableBuilder( - column: $table.microsoftCompletedDateTime, + GeneratedColumn get targetComponentKey => $composableBuilder( + column: $table.targetComponentKey, builder: (column) => column, ); - GeneratedColumn get microsoftCompletedTimeZone => $composableBuilder( - column: $table.microsoftCompletedTimeZone, + GeneratedColumn get mutationScope => $composableBuilder( + column: $table.mutationScope, builder: (column) => column, ); - GeneratedColumn get microsoftChecklistItemsJson => $composableBuilder( - column: $table.microsoftChecklistItemsJson, + GeneratedColumn get destinationCollectionHref => $composableBuilder( + column: $table.destinationCollectionHref, builder: (column) => column, ); - GeneratedColumn get recurrenceJson => $composableBuilder( - column: $table.recurrenceJson, + GeneratedColumn get destinationMemberHref => $composableBuilder( + column: $table.destinationMemberHref, builder: (column) => column, ); - GeneratedColumn get importance => $composableBuilder( - column: $table.importance, + GeneratedColumn get conflictState => $composableBuilder( + column: $table.conflictState, builder: (column) => column, ); - GeneratedColumn get categoriesJson => $composableBuilder( - column: $table.categoriesJson, + GeneratedColumn get retryClassification => $composableBuilder( + column: $table.retryClassification, builder: (column) => column, ); - GeneratedColumn get hasAttachments => $composableBuilder( - column: $table.hasAttachments, + GeneratedColumn get localTempId => $composableBuilder( + column: $table.localTempId, builder: (column) => column, ); - GeneratedColumn get providerMetadataJson => $composableBuilder( - column: $table.providerMetadataJson, + GeneratedColumn get dependsOnOpId => $composableBuilder( + column: $table.dependsOnOpId, builder: (column) => column, ); - GeneratedColumn get deleted => - $composableBuilder(column: $table.deleted, builder: (column) => column); - - GeneratedColumn get hidden => - $composableBuilder(column: $table.hidden, builder: (column) => column); - - GeneratedColumn get linksJson => - $composableBuilder(column: $table.linksJson, builder: (column) => column); - - GeneratedColumn get webViewLink => $composableBuilder( - column: $table.webViewLink, + GeneratedColumn get requestJson => $composableBuilder( + column: $table.requestJson, builder: (column) => column, ); - GeneratedColumn get assignmentInfoJson => $composableBuilder( - column: $table.assignmentInfoJson, + GeneratedColumn get baselineUpdatedUtc => $composableBuilder( + column: $table.baselineUpdatedUtc, builder: (column) => column, ); - GeneratedColumn get rawJson => - $composableBuilder(column: $table.rawJson, builder: (column) => column); - - GeneratedColumn get serverMissing => $composableBuilder( - column: $table.serverMissing, + GeneratedColumn get baselineRawJson => $composableBuilder( + column: $table.baselineRawJson, builder: (column) => column, ); - GeneratedColumn get localDirty => $composableBuilder( - column: $table.localDirty, + GeneratedColumn get attemptCount => $composableBuilder( + column: $table.attemptCount, builder: (column) => column, ); - GeneratedColumn get pendingDelete => $composableBuilder( - column: $table.pendingDelete, + GeneratedColumn get nextAttemptAtUtc => $composableBuilder( + column: $table.nextAttemptAtUtc, builder: (column) => column, ); - GeneratedColumn get pendingMove => $composableBuilder( - column: $table.pendingMove, + GeneratedColumn get lastErrorCode => $composableBuilder( + column: $table.lastErrorCode, builder: (column) => column, ); - GeneratedColumn get localCreated => $composableBuilder( - column: $table.localCreated, + GeneratedColumn get lastErrorMessage => $composableBuilder( + column: $table.lastErrorMessage, builder: (column) => column, ); - GeneratedColumn get syncBaseUpdatedUtc => $composableBuilder( - column: $table.syncBaseUpdatedUtc, - builder: (column) => column, - ); + GeneratedColumn get state => + $composableBuilder(column: $table.state, builder: (column) => column); - GeneratedColumn get lastSyncedAtUtc => $composableBuilder( - column: $table.lastSyncedAtUtc, - builder: (column) => column, - ); + GeneratedColumn get lastError => + $composableBuilder(column: $table.lastError, builder: (column) => column); - GeneratedColumn get createdLocalAtUtc => $composableBuilder( - column: $table.createdLocalAtUtc, + GeneratedColumn get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, builder: (column) => column, ); - GeneratedColumn get updatedLocalAtUtc => $composableBuilder( - column: $table.updatedLocalAtUtc, + GeneratedColumn get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, builder: (column) => column, ); @@ -31692,21 +35524,44 @@ class $$TasksTableAnnotationComposer return composer; } - $$DavObjectComponentsTableAnnotationComposer get davComponentId { - final $$DavObjectComponentsTableAnnotationComposer composer = + $$DavCollectionsTableAnnotationComposer get destinationCollectionId { + final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.destinationCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavCollectionsTableAnnotationComposer( + $db: $db, + $table: $db.davCollections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$DavConflictSnapshotsTableAnnotationComposer get conflictSnapshotId { + final $$DavConflictSnapshotsTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.davComponentId, - referencedTable: $db.davObjectComponents, + getCurrentColumn: (t) => t.conflictSnapshotId, + referencedTable: $db.davConflictSnapshots, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavObjectComponentsTableAnnotationComposer( + }) => $$DavConflictSnapshotsTableAnnotationComposer( $db: $db, - $table: $db.davObjectComponents, + $table: $db.davConflictSnapshots, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -31717,323 +35572,216 @@ class $$TasksTableAnnotationComposer } } -class $$TasksTableTableManager +class $$PendingOpsTableTableManager extends RootTableManager< _$AppDatabase, - $TasksTable, - Task, - $$TasksTableFilterComposer, - $$TasksTableOrderingComposer, - $$TasksTableAnnotationComposer, - $$TasksTableCreateCompanionBuilder, - $$TasksTableUpdateCompanionBuilder, - (Task, $$TasksTableReferences), - Task, + $PendingOpsTable, + PendingOp, + $$PendingOpsTableFilterComposer, + $$PendingOpsTableOrderingComposer, + $$PendingOpsTableAnnotationComposer, + $$PendingOpsTableCreateCompanionBuilder, + $$PendingOpsTableUpdateCompanionBuilder, + (PendingOp, $$PendingOpsTableReferences), + PendingOp, PrefetchHooks Function({ bool accountId, bool davCollectionId, bool davObjectId, - bool davComponentId, + bool destinationCollectionId, + bool conflictSnapshotId, }) > { - $$TasksTableTableManager(_$AppDatabase db, $TasksTable table) + $$PendingOpsTableTableManager(_$AppDatabase db, $PendingOpsTable table) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$TasksTableFilterComposer($db: db, $table: table), + $$PendingOpsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$TasksTableOrderingComposer($db: db, $table: table), + $$PendingOpsTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$TasksTableAnnotationComposer($db: db, $table: table), + $$PendingOpsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ - Value accountId = const Value.absent(), - Value taskListId = const Value.absent(), Value id = const Value.absent(), - Value davCollectionId = const Value.absent(), - Value davObjectId = const Value.absent(), - Value davComponentId = const Value.absent(), - Value icalUid = const Value.absent(), - Value recurrenceIdKey = const Value.absent(), - Value icalPriority = const Value.absent(), - Value percentComplete = const Value.absent(), - Value taskLocation = const Value.absent(), - Value taskUrl = const Value.absent(), - Value taskClassification = const Value.absent(), - Value taskPinned = const Value.absent(), - Value taskHideSubtasks = const Value.absent(), - Value taskHideCompletedSubtasks = const Value.absent(), - Value taskAlarmsJson = const Value.absent(), - Value parentUid = const Value.absent(), - Value sortOrder = const Value.absent(), - Value providerExtensionProjectionJson = - const Value.absent(), - Value projectionVersion = const Value.absent(), - Value kind = const Value.absent(), - Value etag = const Value.absent(), - Value title = const Value.absent(), - Value updatedUtc = const Value.absent(), - Value selfLink = const Value.absent(), - Value parent = const Value.absent(), - Value position = const Value.absent(), - Value notes = const Value.absent(), - Value status = const Value.absent(), - Value dueUtc = const Value.absent(), - Value completedUtc = const Value.absent(), - Value providerStatus = const Value.absent(), - Value bodyContent = const Value.absent(), - Value bodyContentType = const Value.absent(), - Value microsoftDueDateTime = const Value.absent(), - Value microsoftDueTimeZone = const Value.absent(), - Value microsoftStartDateTime = const Value.absent(), - Value microsoftStartTimeZone = const Value.absent(), - Value microsoftReminderDateTime = const Value.absent(), - Value microsoftReminderTimeZone = const Value.absent(), - Value microsoftIsReminderOn = const Value.absent(), - Value microsoftCompletedDateTime = - const Value.absent(), - Value microsoftCompletedTimeZone = - const Value.absent(), - Value microsoftChecklistItemsJson = - const Value.absent(), - Value recurrenceJson = const Value.absent(), - Value importance = const Value.absent(), - Value categoriesJson = const Value.absent(), - Value hasAttachments = const Value.absent(), - Value providerMetadataJson = const Value.absent(), - Value deleted = const Value.absent(), - Value hidden = const Value.absent(), - Value linksJson = const Value.absent(), - Value webViewLink = const Value.absent(), - Value assignmentInfoJson = const Value.absent(), - Value rawJson = const Value.absent(), - Value serverMissing = const Value.absent(), - Value localDirty = const Value.absent(), - Value pendingDelete = const Value.absent(), - Value pendingMove = const Value.absent(), - Value localCreated = const Value.absent(), - Value syncBaseUpdatedUtc = const Value.absent(), - Value lastSyncedAtUtc = const Value.absent(), - Value createdLocalAtUtc = const Value.absent(), - Value updatedLocalAtUtc = const Value.absent(), + Value accountId = const Value.absent(), + Value provider = const Value.absent(), + Value entityType = const Value.absent(), + Value operation = const Value.absent(), + Value operationType = const Value.absent(), + Value taskListId = const Value.absent(), + Value taskId = const Value.absent(), + Value calendarSourceId = const Value.absent(), + Value providerCalendarId = const Value.absent(), + Value eventId = const Value.absent(), + Value davCollectionId = const Value.absent(), + Value davCollectionHref = const Value.absent(), + Value davObjectId = const Value.absent(), + Value davMemberHref = const Value.absent(), + Value baselineEtag = const Value.absent(), + Value baselineRawIcs = const Value.absent(), + Value mutationPatchJson = const Value.absent(), + Value mutationPatchSchemaVersion = const Value.absent(), + Value targetComponentKey = const Value.absent(), + Value mutationScope = const Value.absent(), + Value destinationCollectionId = const Value.absent(), + Value destinationCollectionHref = const Value.absent(), + Value destinationMemberHref = const Value.absent(), + Value conflictState = const Value.absent(), + Value conflictSnapshotId = const Value.absent(), + Value retryClassification = const Value.absent(), + Value localTempId = const Value.absent(), + Value dependsOnOpId = const Value.absent(), + Value requestJson = const Value.absent(), + Value baselineUpdatedUtc = const Value.absent(), + Value baselineRawJson = const Value.absent(), + Value attemptCount = const Value.absent(), + Value nextAttemptAtUtc = const Value.absent(), + Value lastErrorCode = const Value.absent(), + Value lastErrorMessage = const Value.absent(), + Value state = const Value.absent(), + Value lastError = const Value.absent(), + Value createdAtUtc = const Value.absent(), + Value updatedAtUtc = const Value.absent(), Value rowid = const Value.absent(), - }) => TasksCompanion( + }) => PendingOpsCompanion( + id: id, accountId: accountId, + provider: provider, + entityType: entityType, + operation: operation, + operationType: operationType, taskListId: taskListId, - id: id, + taskId: taskId, + calendarSourceId: calendarSourceId, + providerCalendarId: providerCalendarId, + eventId: eventId, davCollectionId: davCollectionId, + davCollectionHref: davCollectionHref, davObjectId: davObjectId, - davComponentId: davComponentId, - icalUid: icalUid, - recurrenceIdKey: recurrenceIdKey, - icalPriority: icalPriority, - percentComplete: percentComplete, - taskLocation: taskLocation, - taskUrl: taskUrl, - taskClassification: taskClassification, - taskPinned: taskPinned, - taskHideSubtasks: taskHideSubtasks, - taskHideCompletedSubtasks: taskHideCompletedSubtasks, - taskAlarmsJson: taskAlarmsJson, - parentUid: parentUid, - sortOrder: sortOrder, - providerExtensionProjectionJson: - providerExtensionProjectionJson, - projectionVersion: projectionVersion, - kind: kind, - etag: etag, - title: title, - updatedUtc: updatedUtc, - selfLink: selfLink, - parent: parent, - position: position, - notes: notes, - status: status, - dueUtc: dueUtc, - completedUtc: completedUtc, - providerStatus: providerStatus, - bodyContent: bodyContent, - bodyContentType: bodyContentType, - microsoftDueDateTime: microsoftDueDateTime, - microsoftDueTimeZone: microsoftDueTimeZone, - microsoftStartDateTime: microsoftStartDateTime, - microsoftStartTimeZone: microsoftStartTimeZone, - microsoftReminderDateTime: microsoftReminderDateTime, - microsoftReminderTimeZone: microsoftReminderTimeZone, - microsoftIsReminderOn: microsoftIsReminderOn, - microsoftCompletedDateTime: microsoftCompletedDateTime, - microsoftCompletedTimeZone: microsoftCompletedTimeZone, - microsoftChecklistItemsJson: microsoftChecklistItemsJson, - recurrenceJson: recurrenceJson, - importance: importance, - categoriesJson: categoriesJson, - hasAttachments: hasAttachments, - providerMetadataJson: providerMetadataJson, - deleted: deleted, - hidden: hidden, - linksJson: linksJson, - webViewLink: webViewLink, - assignmentInfoJson: assignmentInfoJson, - rawJson: rawJson, - serverMissing: serverMissing, - localDirty: localDirty, - pendingDelete: pendingDelete, - pendingMove: pendingMove, - localCreated: localCreated, - syncBaseUpdatedUtc: syncBaseUpdatedUtc, - lastSyncedAtUtc: lastSyncedAtUtc, - createdLocalAtUtc: createdLocalAtUtc, - updatedLocalAtUtc: updatedLocalAtUtc, + davMemberHref: davMemberHref, + baselineEtag: baselineEtag, + baselineRawIcs: baselineRawIcs, + mutationPatchJson: mutationPatchJson, + mutationPatchSchemaVersion: mutationPatchSchemaVersion, + targetComponentKey: targetComponentKey, + mutationScope: mutationScope, + destinationCollectionId: destinationCollectionId, + destinationCollectionHref: destinationCollectionHref, + destinationMemberHref: destinationMemberHref, + conflictState: conflictState, + conflictSnapshotId: conflictSnapshotId, + retryClassification: retryClassification, + localTempId: localTempId, + dependsOnOpId: dependsOnOpId, + requestJson: requestJson, + baselineUpdatedUtc: baselineUpdatedUtc, + baselineRawJson: baselineRawJson, + attemptCount: attemptCount, + nextAttemptAtUtc: nextAttemptAtUtc, + lastErrorCode: lastErrorCode, + lastErrorMessage: lastErrorMessage, + state: state, + lastError: lastError, + createdAtUtc: createdAtUtc, + updatedAtUtc: updatedAtUtc, rowid: rowid, ), createCompanionCallback: ({ - required String accountId, - required String taskListId, required String id, + required String accountId, + Value provider = const Value.absent(), + required String entityType, + required String operation, + Value operationType = const Value.absent(), + Value taskListId = const Value.absent(), + Value taskId = const Value.absent(), + Value calendarSourceId = const Value.absent(), + Value providerCalendarId = const Value.absent(), + Value eventId = const Value.absent(), Value davCollectionId = const Value.absent(), + Value davCollectionHref = const Value.absent(), Value davObjectId = const Value.absent(), - Value davComponentId = const Value.absent(), - Value icalUid = const Value.absent(), - Value recurrenceIdKey = const Value.absent(), - Value icalPriority = const Value.absent(), - Value percentComplete = const Value.absent(), - Value taskLocation = const Value.absent(), - Value taskUrl = const Value.absent(), - Value taskClassification = const Value.absent(), - Value taskPinned = const Value.absent(), - Value taskHideSubtasks = const Value.absent(), - Value taskHideCompletedSubtasks = const Value.absent(), - Value taskAlarmsJson = const Value.absent(), - Value parentUid = const Value.absent(), - Value sortOrder = const Value.absent(), - Value providerExtensionProjectionJson = - const Value.absent(), - Value projectionVersion = const Value.absent(), - Value kind = const Value.absent(), - Value etag = const Value.absent(), - required String title, - Value updatedUtc = const Value.absent(), - Value selfLink = const Value.absent(), - Value parent = const Value.absent(), - Value position = const Value.absent(), - Value notes = const Value.absent(), - Value status = const Value.absent(), - Value dueUtc = const Value.absent(), - Value completedUtc = const Value.absent(), - Value providerStatus = const Value.absent(), - Value bodyContent = const Value.absent(), - Value bodyContentType = const Value.absent(), - Value microsoftDueDateTime = const Value.absent(), - Value microsoftDueTimeZone = const Value.absent(), - Value microsoftStartDateTime = const Value.absent(), - Value microsoftStartTimeZone = const Value.absent(), - Value microsoftReminderDateTime = const Value.absent(), - Value microsoftReminderTimeZone = const Value.absent(), - Value microsoftIsReminderOn = const Value.absent(), - Value microsoftCompletedDateTime = - const Value.absent(), - Value microsoftCompletedTimeZone = - const Value.absent(), - Value microsoftChecklistItemsJson = - const Value.absent(), - Value recurrenceJson = const Value.absent(), - Value importance = const Value.absent(), - Value categoriesJson = const Value.absent(), - Value hasAttachments = const Value.absent(), - Value providerMetadataJson = const Value.absent(), - Value deleted = const Value.absent(), - Value hidden = const Value.absent(), - Value linksJson = const Value.absent(), - Value webViewLink = const Value.absent(), - Value assignmentInfoJson = const Value.absent(), - required String rawJson, - Value serverMissing = const Value.absent(), - Value localDirty = const Value.absent(), - Value pendingDelete = const Value.absent(), - Value pendingMove = const Value.absent(), - Value localCreated = const Value.absent(), - Value syncBaseUpdatedUtc = const Value.absent(), - Value lastSyncedAtUtc = const Value.absent(), - required String createdLocalAtUtc, - required String updatedLocalAtUtc, + Value davMemberHref = const Value.absent(), + Value baselineEtag = const Value.absent(), + Value baselineRawIcs = const Value.absent(), + Value mutationPatchJson = const Value.absent(), + Value mutationPatchSchemaVersion = const Value.absent(), + Value targetComponentKey = const Value.absent(), + Value mutationScope = const Value.absent(), + Value destinationCollectionId = const Value.absent(), + Value destinationCollectionHref = const Value.absent(), + Value destinationMemberHref = const Value.absent(), + Value conflictState = const Value.absent(), + Value conflictSnapshotId = const Value.absent(), + Value retryClassification = const Value.absent(), + Value localTempId = const Value.absent(), + Value dependsOnOpId = const Value.absent(), + required String requestJson, + Value baselineUpdatedUtc = const Value.absent(), + Value baselineRawJson = const Value.absent(), + Value attemptCount = const Value.absent(), + Value nextAttemptAtUtc = const Value.absent(), + Value lastErrorCode = const Value.absent(), + Value lastErrorMessage = const Value.absent(), + Value state = const Value.absent(), + Value lastError = const Value.absent(), + required String createdAtUtc, + required String updatedAtUtc, Value rowid = const Value.absent(), - }) => TasksCompanion.insert( + }) => PendingOpsCompanion.insert( + id: id, accountId: accountId, + provider: provider, + entityType: entityType, + operation: operation, + operationType: operationType, taskListId: taskListId, - id: id, + taskId: taskId, + calendarSourceId: calendarSourceId, + providerCalendarId: providerCalendarId, + eventId: eventId, davCollectionId: davCollectionId, + davCollectionHref: davCollectionHref, davObjectId: davObjectId, - davComponentId: davComponentId, - icalUid: icalUid, - recurrenceIdKey: recurrenceIdKey, - icalPriority: icalPriority, - percentComplete: percentComplete, - taskLocation: taskLocation, - taskUrl: taskUrl, - taskClassification: taskClassification, - taskPinned: taskPinned, - taskHideSubtasks: taskHideSubtasks, - taskHideCompletedSubtasks: taskHideCompletedSubtasks, - taskAlarmsJson: taskAlarmsJson, - parentUid: parentUid, - sortOrder: sortOrder, - providerExtensionProjectionJson: - providerExtensionProjectionJson, - projectionVersion: projectionVersion, - kind: kind, - etag: etag, - title: title, - updatedUtc: updatedUtc, - selfLink: selfLink, - parent: parent, - position: position, - notes: notes, - status: status, - dueUtc: dueUtc, - completedUtc: completedUtc, - providerStatus: providerStatus, - bodyContent: bodyContent, - bodyContentType: bodyContentType, - microsoftDueDateTime: microsoftDueDateTime, - microsoftDueTimeZone: microsoftDueTimeZone, - microsoftStartDateTime: microsoftStartDateTime, - microsoftStartTimeZone: microsoftStartTimeZone, - microsoftReminderDateTime: microsoftReminderDateTime, - microsoftReminderTimeZone: microsoftReminderTimeZone, - microsoftIsReminderOn: microsoftIsReminderOn, - microsoftCompletedDateTime: microsoftCompletedDateTime, - microsoftCompletedTimeZone: microsoftCompletedTimeZone, - microsoftChecklistItemsJson: microsoftChecklistItemsJson, - recurrenceJson: recurrenceJson, - importance: importance, - categoriesJson: categoriesJson, - hasAttachments: hasAttachments, - providerMetadataJson: providerMetadataJson, - deleted: deleted, - hidden: hidden, - linksJson: linksJson, - webViewLink: webViewLink, - assignmentInfoJson: assignmentInfoJson, - rawJson: rawJson, - serverMissing: serverMissing, - localDirty: localDirty, - pendingDelete: pendingDelete, - pendingMove: pendingMove, - localCreated: localCreated, - syncBaseUpdatedUtc: syncBaseUpdatedUtc, - lastSyncedAtUtc: lastSyncedAtUtc, - createdLocalAtUtc: createdLocalAtUtc, - updatedLocalAtUtc: updatedLocalAtUtc, + davMemberHref: davMemberHref, + baselineEtag: baselineEtag, + baselineRawIcs: baselineRawIcs, + mutationPatchJson: mutationPatchJson, + mutationPatchSchemaVersion: mutationPatchSchemaVersion, + targetComponentKey: targetComponentKey, + mutationScope: mutationScope, + destinationCollectionId: destinationCollectionId, + destinationCollectionHref: destinationCollectionHref, + destinationMemberHref: destinationMemberHref, + conflictState: conflictState, + conflictSnapshotId: conflictSnapshotId, + retryClassification: retryClassification, + localTempId: localTempId, + dependsOnOpId: dependsOnOpId, + requestJson: requestJson, + baselineUpdatedUtc: baselineUpdatedUtc, + baselineRawJson: baselineRawJson, + attemptCount: attemptCount, + nextAttemptAtUtc: nextAttemptAtUtc, + lastErrorCode: lastErrorCode, + lastErrorMessage: lastErrorMessage, + state: state, + lastError: lastError, + createdAtUtc: createdAtUtc, + updatedAtUtc: updatedAtUtc, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( - (e) => - (e.readTable(table), $$TasksTableReferences(db, table, e)), + (e) => ( + e.readTable(table), + $$PendingOpsTableReferences(db, table, e), + ), ) .toList(), prefetchHooksCallback: @@ -32041,7 +35789,8 @@ class $$TasksTableTableManager accountId = false, davCollectionId = false, davObjectId = false, - davComponentId = false, + destinationCollectionId = false, + conflictSnapshotId = false, }) { return PrefetchHooks( db: db, @@ -32067,11 +35816,12 @@ class $$TasksTableTableManager state.withJoin( currentTable: table, currentColumn: table.accountId, - referencedTable: $$TasksTableReferences + referencedTable: $$PendingOpsTableReferences ._accountIdTable(db), - referencedColumn: $$TasksTableReferences - ._accountIdTable(db) - .id, + referencedColumn: + $$PendingOpsTableReferences + ._accountIdTable(db) + .id, ) as T; } @@ -32080,11 +35830,12 @@ class $$TasksTableTableManager state.withJoin( currentTable: table, currentColumn: table.davCollectionId, - referencedTable: $$TasksTableReferences + referencedTable: $$PendingOpsTableReferences ._davCollectionIdTable(db), - referencedColumn: $$TasksTableReferences - ._davCollectionIdTable(db) - .id, + referencedColumn: + $$PendingOpsTableReferences + ._davCollectionIdTable(db) + .id, ) as T; } @@ -32093,24 +35844,41 @@ class $$TasksTableTableManager state.withJoin( currentTable: table, currentColumn: table.davObjectId, - referencedTable: $$TasksTableReferences + referencedTable: $$PendingOpsTableReferences ._davObjectIdTable(db), - referencedColumn: $$TasksTableReferences - ._davObjectIdTable(db) - .id, + referencedColumn: + $$PendingOpsTableReferences + ._davObjectIdTable(db) + .id, ) as T; } - if (davComponentId) { + if (destinationCollectionId) { state = state.withJoin( currentTable: table, - currentColumn: table.davComponentId, - referencedTable: $$TasksTableReferences - ._davComponentIdTable(db), - referencedColumn: $$TasksTableReferences - ._davComponentIdTable(db) - .id, + currentColumn: + table.destinationCollectionId, + referencedTable: $$PendingOpsTableReferences + ._destinationCollectionIdTable(db), + referencedColumn: + $$PendingOpsTableReferences + ._destinationCollectionIdTable(db) + .id, + ) + as T; + } + if (conflictSnapshotId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.conflictSnapshotId, + referencedTable: $$PendingOpsTableReferences + ._conflictSnapshotIdTable(db), + referencedColumn: + $$PendingOpsTableReferences + ._conflictSnapshotIdTable(db) + .id, ) as T; } @@ -32126,122 +35894,65 @@ class $$TasksTableTableManager ); } -typedef $$TasksTableProcessedTableManager = +typedef $$PendingOpsTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $TasksTable, - Task, - $$TasksTableFilterComposer, - $$TasksTableOrderingComposer, - $$TasksTableAnnotationComposer, - $$TasksTableCreateCompanionBuilder, - $$TasksTableUpdateCompanionBuilder, - (Task, $$TasksTableReferences), - Task, + $PendingOpsTable, + PendingOp, + $$PendingOpsTableFilterComposer, + $$PendingOpsTableOrderingComposer, + $$PendingOpsTableAnnotationComposer, + $$PendingOpsTableCreateCompanionBuilder, + $$PendingOpsTableUpdateCompanionBuilder, + (PendingOp, $$PendingOpsTableReferences), + PendingOp, PrefetchHooks Function({ bool accountId, bool davCollectionId, bool davObjectId, - bool davComponentId, - }) - >; -typedef $$PendingOpsTableCreateCompanionBuilder = - PendingOpsCompanion Function({ - required String id, - required String accountId, - Value provider, - required String entityType, - required String operation, - Value operationType, - Value taskListId, - Value taskId, - Value calendarSourceId, - Value providerCalendarId, - Value eventId, - Value davCollectionId, - Value davCollectionHref, - Value davObjectId, - Value davMemberHref, - Value baselineEtag, - Value baselineRawIcs, - Value mutationPatchJson, - Value mutationPatchSchemaVersion, - Value targetComponentKey, - Value mutationScope, - Value destinationCollectionId, - Value destinationCollectionHref, - Value destinationMemberHref, - Value conflictState, - Value conflictSnapshotId, - Value retryClassification, - Value localTempId, - Value dependsOnOpId, - required String requestJson, - Value baselineUpdatedUtc, - Value baselineRawJson, - Value attemptCount, - Value nextAttemptAtUtc, - Value lastErrorCode, - Value lastErrorMessage, - Value state, - Value lastError, - required String createdAtUtc, - required String updatedAtUtc, - Value rowid, - }); -typedef $$PendingOpsTableUpdateCompanionBuilder = - PendingOpsCompanion Function({ - Value id, - Value accountId, - Value provider, - Value entityType, - Value operation, - Value operationType, - Value taskListId, - Value taskId, - Value calendarSourceId, - Value providerCalendarId, - Value eventId, - Value davCollectionId, - Value davCollectionHref, - Value davObjectId, - Value davMemberHref, - Value baselineEtag, - Value baselineRawIcs, - Value mutationPatchJson, - Value mutationPatchSchemaVersion, - Value targetComponentKey, - Value mutationScope, - Value destinationCollectionId, - Value destinationCollectionHref, - Value destinationMemberHref, - Value conflictState, - Value conflictSnapshotId, - Value retryClassification, - Value localTempId, - Value dependsOnOpId, - Value requestJson, - Value baselineUpdatedUtc, - Value baselineRawJson, - Value attemptCount, - Value nextAttemptAtUtc, - Value lastErrorCode, - Value lastErrorMessage, - Value state, - Value lastError, - Value createdAtUtc, - Value updatedAtUtc, + bool destinationCollectionId, + bool conflictSnapshotId, + }) + >; +typedef $$SyncRunsTableCreateCompanionBuilder = + SyncRunsCompanion Function({ + required String id, + required String accountId, + Value provider, + required String mode, + required String startedAtUtc, + Value finishedAtUtc, + required String status, + Value taskListsSeen, + Value tasksSeen, + Value pendingOpsApplied, + Value errorCode, + Value errorMessage, + Value rowid, + }); +typedef $$SyncRunsTableUpdateCompanionBuilder = + SyncRunsCompanion Function({ + Value id, + Value accountId, + Value provider, + Value mode, + Value startedAtUtc, + Value finishedAtUtc, + Value status, + Value taskListsSeen, + Value tasksSeen, + Value pendingOpsApplied, + Value errorCode, + Value errorMessage, Value rowid, }); -final class $$PendingOpsTableReferences - extends BaseReferences<_$AppDatabase, $PendingOpsTable, PendingOp> { - $$PendingOpsTableReferences(super.$_db, super.$_table, super.$_typedResult); +final class $$SyncRunsTableReferences + extends BaseReferences<_$AppDatabase, $SyncRunsTable, SyncRun> { + $$SyncRunsTableReferences(super.$_db, super.$_table, super.$_typedResult); - static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.pendingOps.accountId, db.accounts.id), - ); + static $AccountsTable _accountIdTable(_$AppDatabase db) => db.accounts + .createAlias($_aliasNameGenerator(db.syncRuns.accountId, db.accounts.id)); $$AccountsTableProcessedTableManager get accountId { final $_column = $_itemColumn('account_id')!; @@ -32256,99 +35967,11 @@ final class $$PendingOpsTableReferences manager.$state.copyWith(prefetchedData: [item]), ); } - - static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => - db.davCollections.createAlias( - $_aliasNameGenerator( - db.pendingOps.davCollectionId, - db.davCollections.id, - ), - ); - - $$DavCollectionsTableProcessedTableManager? get davCollectionId { - final $_column = $_itemColumn('dav_collection_id'); - if ($_column == null) return null; - final manager = $$DavCollectionsTableTableManager( - $_db, - $_db.davCollections, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_davCollectionIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } - - static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => - db.davObjects.createAlias( - $_aliasNameGenerator(db.pendingOps.davObjectId, db.davObjects.id), - ); - - $$DavObjectsTableProcessedTableManager? get davObjectId { - final $_column = $_itemColumn('dav_object_id'); - if ($_column == null) return null; - final manager = $$DavObjectsTableTableManager( - $_db, - $_db.davObjects, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_davObjectIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } - - static $DavCollectionsTable _destinationCollectionIdTable(_$AppDatabase db) => - db.davCollections.createAlias( - $_aliasNameGenerator( - db.pendingOps.destinationCollectionId, - db.davCollections.id, - ), - ); - - $$DavCollectionsTableProcessedTableManager? get destinationCollectionId { - final $_column = $_itemColumn('destination_collection_id'); - if ($_column == null) return null; - final manager = $$DavCollectionsTableTableManager( - $_db, - $_db.davCollections, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull( - _destinationCollectionIdTable($_db), - ); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } - - static $DavConflictSnapshotsTable _conflictSnapshotIdTable( - _$AppDatabase db, - ) => db.davConflictSnapshots.createAlias( - $_aliasNameGenerator( - db.pendingOps.conflictSnapshotId, - db.davConflictSnapshots.id, - ), - ); - - $$DavConflictSnapshotsTableProcessedTableManager? get conflictSnapshotId { - final $_column = $_itemColumn('conflict_snapshot_id'); - if ($_column == null) return null; - final manager = $$DavConflictSnapshotsTableTableManager( - $_db, - $_db.davConflictSnapshots, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_conflictSnapshotIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } } -class $$PendingOpsTableFilterComposer - extends Composer<_$AppDatabase, $PendingOpsTable> { - $$PendingOpsTableFilterComposer({ +class $$SyncRunsTableFilterComposer + extends Composer<_$AppDatabase, $SyncRunsTable> { + $$SyncRunsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -32365,168 +35988,48 @@ class $$PendingOpsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get entityType => $composableBuilder( - column: $table.entityType, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get operation => $composableBuilder( - column: $table.operation, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get operationType => $composableBuilder( - column: $table.operationType, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get taskListId => $composableBuilder( - column: $table.taskListId, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get taskId => $composableBuilder( - column: $table.taskId, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get calendarSourceId => $composableBuilder( - column: $table.calendarSourceId, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get providerCalendarId => $composableBuilder( - column: $table.providerCalendarId, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get eventId => $composableBuilder( - column: $table.eventId, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get davCollectionHref => $composableBuilder( - column: $table.davCollectionHref, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get davMemberHref => $composableBuilder( - column: $table.davMemberHref, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get baselineEtag => $composableBuilder( - column: $table.baselineEtag, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get baselineRawIcs => $composableBuilder( - column: $table.baselineRawIcs, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get mutationPatchJson => $composableBuilder( - column: $table.mutationPatchJson, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get mutationPatchSchemaVersion => $composableBuilder( - column: $table.mutationPatchSchemaVersion, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get targetComponentKey => $composableBuilder( - column: $table.targetComponentKey, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get mutationScope => $composableBuilder( - column: $table.mutationScope, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get destinationCollectionHref => $composableBuilder( - column: $table.destinationCollectionHref, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get destinationMemberHref => $composableBuilder( - column: $table.destinationMemberHref, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get conflictState => $composableBuilder( - column: $table.conflictState, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get retryClassification => $composableBuilder( - column: $table.retryClassification, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get localTempId => $composableBuilder( - column: $table.localTempId, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get dependsOnOpId => $composableBuilder( - column: $table.dependsOnOpId, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get requestJson => $composableBuilder( - column: $table.requestJson, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get baselineUpdatedUtc => $composableBuilder( - column: $table.baselineUpdatedUtc, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get baselineRawJson => $composableBuilder( - column: $table.baselineRawJson, + ColumnFilters get mode => $composableBuilder( + column: $table.mode, builder: (column) => ColumnFilters(column), ); - ColumnFilters get attemptCount => $composableBuilder( - column: $table.attemptCount, + ColumnFilters get startedAtUtc => $composableBuilder( + column: $table.startedAtUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get nextAttemptAtUtc => $composableBuilder( - column: $table.nextAttemptAtUtc, + ColumnFilters get finishedAtUtc => $composableBuilder( + column: $table.finishedAtUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get lastErrorCode => $composableBuilder( - column: $table.lastErrorCode, + ColumnFilters get status => $composableBuilder( + column: $table.status, builder: (column) => ColumnFilters(column), ); - ColumnFilters get lastErrorMessage => $composableBuilder( - column: $table.lastErrorMessage, + ColumnFilters get taskListsSeen => $composableBuilder( + column: $table.taskListsSeen, builder: (column) => ColumnFilters(column), ); - ColumnFilters get state => $composableBuilder( - column: $table.state, + ColumnFilters get tasksSeen => $composableBuilder( + column: $table.tasksSeen, builder: (column) => ColumnFilters(column), ); - ColumnFilters get lastError => $composableBuilder( - column: $table.lastError, + ColumnFilters get pendingOpsApplied => $composableBuilder( + column: $table.pendingOpsApplied, builder: (column) => ColumnFilters(column), ); - ColumnFilters get createdAtUtc => $composableBuilder( - column: $table.createdAtUtc, + ColumnFilters get errorCode => $composableBuilder( + column: $table.errorCode, builder: (column) => ColumnFilters(column), ); - ColumnFilters get updatedAtUtc => $composableBuilder( - column: $table.updatedAtUtc, + ColumnFilters get errorMessage => $composableBuilder( + column: $table.errorMessage, builder: (column) => ColumnFilters(column), ); @@ -32552,103 +36055,11 @@ class $$PendingOpsTableFilterComposer ); return composer; } - - $$DavCollectionsTableFilterComposer get davCollectionId { - final $$DavCollectionsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableFilterComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavObjectsTableFilterComposer get davObjectId { - final $$DavObjectsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davObjectId, - referencedTable: $db.davObjects, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableFilterComposer( - $db: $db, - $table: $db.davObjects, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavCollectionsTableFilterComposer get destinationCollectionId { - final $$DavCollectionsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.destinationCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableFilterComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavConflictSnapshotsTableFilterComposer get conflictSnapshotId { - final $$DavConflictSnapshotsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.conflictSnapshotId, - referencedTable: $db.davConflictSnapshots, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavConflictSnapshotsTableFilterComposer( - $db: $db, - $table: $db.davConflictSnapshots, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } } -class $$PendingOpsTableOrderingComposer - extends Composer<_$AppDatabase, $PendingOpsTable> { - $$PendingOpsTableOrderingComposer({ +class $$SyncRunsTableOrderingComposer + extends Composer<_$AppDatabase, $SyncRunsTable> { + $$SyncRunsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -32665,168 +36076,48 @@ class $$PendingOpsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get entityType => $composableBuilder( - column: $table.entityType, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get operation => $composableBuilder( - column: $table.operation, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get operationType => $composableBuilder( - column: $table.operationType, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get taskListId => $composableBuilder( - column: $table.taskListId, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get taskId => $composableBuilder( - column: $table.taskId, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get calendarSourceId => $composableBuilder( - column: $table.calendarSourceId, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get providerCalendarId => $composableBuilder( - column: $table.providerCalendarId, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get eventId => $composableBuilder( - column: $table.eventId, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get davCollectionHref => $composableBuilder( - column: $table.davCollectionHref, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get davMemberHref => $composableBuilder( - column: $table.davMemberHref, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get baselineEtag => $composableBuilder( - column: $table.baselineEtag, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get baselineRawIcs => $composableBuilder( - column: $table.baselineRawIcs, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get mutationPatchJson => $composableBuilder( - column: $table.mutationPatchJson, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get mutationPatchSchemaVersion => $composableBuilder( - column: $table.mutationPatchSchemaVersion, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get targetComponentKey => $composableBuilder( - column: $table.targetComponentKey, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get mutationScope => $composableBuilder( - column: $table.mutationScope, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get destinationCollectionHref => $composableBuilder( - column: $table.destinationCollectionHref, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get destinationMemberHref => $composableBuilder( - column: $table.destinationMemberHref, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get conflictState => $composableBuilder( - column: $table.conflictState, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get retryClassification => $composableBuilder( - column: $table.retryClassification, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get localTempId => $composableBuilder( - column: $table.localTempId, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get dependsOnOpId => $composableBuilder( - column: $table.dependsOnOpId, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get requestJson => $composableBuilder( - column: $table.requestJson, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get baselineUpdatedUtc => $composableBuilder( - column: $table.baselineUpdatedUtc, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get baselineRawJson => $composableBuilder( - column: $table.baselineRawJson, + ColumnOrderings get mode => $composableBuilder( + column: $table.mode, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get attemptCount => $composableBuilder( - column: $table.attemptCount, + ColumnOrderings get startedAtUtc => $composableBuilder( + column: $table.startedAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get nextAttemptAtUtc => $composableBuilder( - column: $table.nextAttemptAtUtc, + ColumnOrderings get finishedAtUtc => $composableBuilder( + column: $table.finishedAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get lastErrorCode => $composableBuilder( - column: $table.lastErrorCode, + ColumnOrderings get status => $composableBuilder( + column: $table.status, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get lastErrorMessage => $composableBuilder( - column: $table.lastErrorMessage, + ColumnOrderings get taskListsSeen => $composableBuilder( + column: $table.taskListsSeen, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get state => $composableBuilder( - column: $table.state, + ColumnOrderings get tasksSeen => $composableBuilder( + column: $table.tasksSeen, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get lastError => $composableBuilder( - column: $table.lastError, + ColumnOrderings get pendingOpsApplied => $composableBuilder( + column: $table.pendingOpsApplied, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get createdAtUtc => $composableBuilder( - column: $table.createdAtUtc, + ColumnOrderings get errorCode => $composableBuilder( + column: $table.errorCode, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get updatedAtUtc => $composableBuilder( - column: $table.updatedAtUtc, + ColumnOrderings get errorMessage => $composableBuilder( + column: $table.errorMessage, builder: (column) => ColumnOrderings(column), ); @@ -32852,104 +36143,11 @@ class $$PendingOpsTableOrderingComposer ); return composer; } - - $$DavCollectionsTableOrderingComposer get davCollectionId { - final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableOrderingComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavObjectsTableOrderingComposer get davObjectId { - final $$DavObjectsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davObjectId, - referencedTable: $db.davObjects, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableOrderingComposer( - $db: $db, - $table: $db.davObjects, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavCollectionsTableOrderingComposer get destinationCollectionId { - final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.destinationCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableOrderingComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavConflictSnapshotsTableOrderingComposer get conflictSnapshotId { - final $$DavConflictSnapshotsTableOrderingComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.conflictSnapshotId, - referencedTable: $db.davConflictSnapshots, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavConflictSnapshotsTableOrderingComposer( - $db: $db, - $table: $db.davConflictSnapshots, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } } -class $$PendingOpsTableAnnotationComposer - extends Composer<_$AppDatabase, $PendingOpsTable> { - $$PendingOpsTableAnnotationComposer({ +class $$SyncRunsTableAnnotationComposer + extends Composer<_$AppDatabase, $SyncRunsTable> { + $$SyncRunsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -32962,158 +36160,40 @@ class $$PendingOpsTableAnnotationComposer GeneratedColumn get provider => $composableBuilder(column: $table.provider, builder: (column) => column); - GeneratedColumn get entityType => $composableBuilder( - column: $table.entityType, - builder: (column) => column, - ); - - GeneratedColumn get operation => - $composableBuilder(column: $table.operation, builder: (column) => column); - - GeneratedColumn get operationType => $composableBuilder( - column: $table.operationType, - builder: (column) => column, - ); - - GeneratedColumn get taskListId => $composableBuilder( - column: $table.taskListId, - builder: (column) => column, - ); - - GeneratedColumn get taskId => - $composableBuilder(column: $table.taskId, builder: (column) => column); - - GeneratedColumn get calendarSourceId => $composableBuilder( - column: $table.calendarSourceId, - builder: (column) => column, - ); - - GeneratedColumn get providerCalendarId => $composableBuilder( - column: $table.providerCalendarId, - builder: (column) => column, - ); - - GeneratedColumn get eventId => - $composableBuilder(column: $table.eventId, builder: (column) => column); - - GeneratedColumn get davCollectionHref => $composableBuilder( - column: $table.davCollectionHref, - builder: (column) => column, - ); - - GeneratedColumn get davMemberHref => $composableBuilder( - column: $table.davMemberHref, - builder: (column) => column, - ); - - GeneratedColumn get baselineEtag => $composableBuilder( - column: $table.baselineEtag, - builder: (column) => column, - ); - - GeneratedColumn get baselineRawIcs => $composableBuilder( - column: $table.baselineRawIcs, - builder: (column) => column, - ); - - GeneratedColumn get mutationPatchJson => $composableBuilder( - column: $table.mutationPatchJson, - builder: (column) => column, - ); - - GeneratedColumn get mutationPatchSchemaVersion => $composableBuilder( - column: $table.mutationPatchSchemaVersion, - builder: (column) => column, - ); - - GeneratedColumn get targetComponentKey => $composableBuilder( - column: $table.targetComponentKey, - builder: (column) => column, - ); - - GeneratedColumn get mutationScope => $composableBuilder( - column: $table.mutationScope, - builder: (column) => column, - ); - - GeneratedColumn get destinationCollectionHref => $composableBuilder( - column: $table.destinationCollectionHref, - builder: (column) => column, - ); - - GeneratedColumn get destinationMemberHref => $composableBuilder( - column: $table.destinationMemberHref, - builder: (column) => column, - ); - - GeneratedColumn get conflictState => $composableBuilder( - column: $table.conflictState, - builder: (column) => column, - ); - - GeneratedColumn get retryClassification => $composableBuilder( - column: $table.retryClassification, - builder: (column) => column, - ); - - GeneratedColumn get localTempId => $composableBuilder( - column: $table.localTempId, - builder: (column) => column, - ); - - GeneratedColumn get dependsOnOpId => $composableBuilder( - column: $table.dependsOnOpId, - builder: (column) => column, - ); - - GeneratedColumn get requestJson => $composableBuilder( - column: $table.requestJson, - builder: (column) => column, - ); + GeneratedColumn get mode => + $composableBuilder(column: $table.mode, builder: (column) => column); - GeneratedColumn get baselineUpdatedUtc => $composableBuilder( - column: $table.baselineUpdatedUtc, + GeneratedColumn get startedAtUtc => $composableBuilder( + column: $table.startedAtUtc, builder: (column) => column, ); - GeneratedColumn get baselineRawJson => $composableBuilder( - column: $table.baselineRawJson, + GeneratedColumn get finishedAtUtc => $composableBuilder( + column: $table.finishedAtUtc, builder: (column) => column, ); - GeneratedColumn get attemptCount => $composableBuilder( - column: $table.attemptCount, - builder: (column) => column, - ); + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); - GeneratedColumn get nextAttemptAtUtc => $composableBuilder( - column: $table.nextAttemptAtUtc, + GeneratedColumn get taskListsSeen => $composableBuilder( + column: $table.taskListsSeen, builder: (column) => column, ); - GeneratedColumn get lastErrorCode => $composableBuilder( - column: $table.lastErrorCode, - builder: (column) => column, - ); + GeneratedColumn get tasksSeen => + $composableBuilder(column: $table.tasksSeen, builder: (column) => column); - GeneratedColumn get lastErrorMessage => $composableBuilder( - column: $table.lastErrorMessage, + GeneratedColumn get pendingOpsApplied => $composableBuilder( + column: $table.pendingOpsApplied, builder: (column) => column, ); - GeneratedColumn get state => - $composableBuilder(column: $table.state, builder: (column) => column); - - GeneratedColumn get lastError => - $composableBuilder(column: $table.lastError, builder: (column) => column); - - GeneratedColumn get createdAtUtc => $composableBuilder( - column: $table.createdAtUtc, - builder: (column) => column, - ); + GeneratedColumn get errorCode => + $composableBuilder(column: $table.errorCode, builder: (column) => column); - GeneratedColumn get updatedAtUtc => $composableBuilder( - column: $table.updatedAtUtc, + GeneratedColumn get errorMessage => $composableBuilder( + column: $table.errorMessage, builder: (column) => column, ); @@ -33139,217 +36219,62 @@ class $$PendingOpsTableAnnotationComposer ); return composer; } - - $$DavCollectionsTableAnnotationComposer get davCollectionId { - final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableAnnotationComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavObjectsTableAnnotationComposer get davObjectId { - final $$DavObjectsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davObjectId, - referencedTable: $db.davObjects, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableAnnotationComposer( - $db: $db, - $table: $db.davObjects, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavCollectionsTableAnnotationComposer get destinationCollectionId { - final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.destinationCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableAnnotationComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavConflictSnapshotsTableAnnotationComposer get conflictSnapshotId { - final $$DavConflictSnapshotsTableAnnotationComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.conflictSnapshotId, - referencedTable: $db.davConflictSnapshots, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavConflictSnapshotsTableAnnotationComposer( - $db: $db, - $table: $db.davConflictSnapshots, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } } -class $$PendingOpsTableTableManager +class $$SyncRunsTableTableManager extends RootTableManager< _$AppDatabase, - $PendingOpsTable, - PendingOp, - $$PendingOpsTableFilterComposer, - $$PendingOpsTableOrderingComposer, - $$PendingOpsTableAnnotationComposer, - $$PendingOpsTableCreateCompanionBuilder, - $$PendingOpsTableUpdateCompanionBuilder, - (PendingOp, $$PendingOpsTableReferences), - PendingOp, - PrefetchHooks Function({ - bool accountId, - bool davCollectionId, - bool davObjectId, - bool destinationCollectionId, - bool conflictSnapshotId, - }) + $SyncRunsTable, + SyncRun, + $$SyncRunsTableFilterComposer, + $$SyncRunsTableOrderingComposer, + $$SyncRunsTableAnnotationComposer, + $$SyncRunsTableCreateCompanionBuilder, + $$SyncRunsTableUpdateCompanionBuilder, + (SyncRun, $$SyncRunsTableReferences), + SyncRun, + PrefetchHooks Function({bool accountId}) > { - $$PendingOpsTableTableManager(_$AppDatabase db, $PendingOpsTable table) + $$SyncRunsTableTableManager(_$AppDatabase db, $SyncRunsTable table) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$PendingOpsTableFilterComposer($db: db, $table: table), + $$SyncRunsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$PendingOpsTableOrderingComposer($db: db, $table: table), + $$SyncRunsTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$PendingOpsTableAnnotationComposer($db: db, $table: table), + $$SyncRunsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), Value accountId = const Value.absent(), Value provider = const Value.absent(), - Value entityType = const Value.absent(), - Value operation = const Value.absent(), - Value operationType = const Value.absent(), - Value taskListId = const Value.absent(), - Value taskId = const Value.absent(), - Value calendarSourceId = const Value.absent(), - Value providerCalendarId = const Value.absent(), - Value eventId = const Value.absent(), - Value davCollectionId = const Value.absent(), - Value davCollectionHref = const Value.absent(), - Value davObjectId = const Value.absent(), - Value davMemberHref = const Value.absent(), - Value baselineEtag = const Value.absent(), - Value baselineRawIcs = const Value.absent(), - Value mutationPatchJson = const Value.absent(), - Value mutationPatchSchemaVersion = const Value.absent(), - Value targetComponentKey = const Value.absent(), - Value mutationScope = const Value.absent(), - Value destinationCollectionId = const Value.absent(), - Value destinationCollectionHref = const Value.absent(), - Value destinationMemberHref = const Value.absent(), - Value conflictState = const Value.absent(), - Value conflictSnapshotId = const Value.absent(), - Value retryClassification = const Value.absent(), - Value localTempId = const Value.absent(), - Value dependsOnOpId = const Value.absent(), - Value requestJson = const Value.absent(), - Value baselineUpdatedUtc = const Value.absent(), - Value baselineRawJson = const Value.absent(), - Value attemptCount = const Value.absent(), - Value nextAttemptAtUtc = const Value.absent(), - Value lastErrorCode = const Value.absent(), - Value lastErrorMessage = const Value.absent(), - Value state = const Value.absent(), - Value lastError = const Value.absent(), - Value createdAtUtc = const Value.absent(), - Value updatedAtUtc = const Value.absent(), + Value mode = const Value.absent(), + Value startedAtUtc = const Value.absent(), + Value finishedAtUtc = const Value.absent(), + Value status = const Value.absent(), + Value taskListsSeen = const Value.absent(), + Value tasksSeen = const Value.absent(), + Value pendingOpsApplied = const Value.absent(), + Value errorCode = const Value.absent(), + Value errorMessage = const Value.absent(), Value rowid = const Value.absent(), - }) => PendingOpsCompanion( + }) => SyncRunsCompanion( id: id, accountId: accountId, provider: provider, - entityType: entityType, - operation: operation, - operationType: operationType, - taskListId: taskListId, - taskId: taskId, - calendarSourceId: calendarSourceId, - providerCalendarId: providerCalendarId, - eventId: eventId, - davCollectionId: davCollectionId, - davCollectionHref: davCollectionHref, - davObjectId: davObjectId, - davMemberHref: davMemberHref, - baselineEtag: baselineEtag, - baselineRawIcs: baselineRawIcs, - mutationPatchJson: mutationPatchJson, - mutationPatchSchemaVersion: mutationPatchSchemaVersion, - targetComponentKey: targetComponentKey, - mutationScope: mutationScope, - destinationCollectionId: destinationCollectionId, - destinationCollectionHref: destinationCollectionHref, - destinationMemberHref: destinationMemberHref, - conflictState: conflictState, - conflictSnapshotId: conflictSnapshotId, - retryClassification: retryClassification, - localTempId: localTempId, - dependsOnOpId: dependsOnOpId, - requestJson: requestJson, - baselineUpdatedUtc: baselineUpdatedUtc, - baselineRawJson: baselineRawJson, - attemptCount: attemptCount, - nextAttemptAtUtc: nextAttemptAtUtc, - lastErrorCode: lastErrorCode, - lastErrorMessage: lastErrorMessage, - state: state, - lastError: lastError, - createdAtUtc: createdAtUtc, - updatedAtUtc: updatedAtUtc, + mode: mode, + startedAtUtc: startedAtUtc, + finishedAtUtc: finishedAtUtc, + status: status, + taskListsSeen: taskListsSeen, + tasksSeen: tasksSeen, + pendingOpsApplied: pendingOpsApplied, + errorCode: errorCode, + errorMessage: errorMessage, rowid: rowid, ), createCompanionCallback: @@ -33357,341 +36282,414 @@ class $$PendingOpsTableTableManager required String id, required String accountId, Value provider = const Value.absent(), - required String entityType, - required String operation, - Value operationType = const Value.absent(), - Value taskListId = const Value.absent(), - Value taskId = const Value.absent(), - Value calendarSourceId = const Value.absent(), - Value providerCalendarId = const Value.absent(), - Value eventId = const Value.absent(), - Value davCollectionId = const Value.absent(), - Value davCollectionHref = const Value.absent(), - Value davObjectId = const Value.absent(), - Value davMemberHref = const Value.absent(), - Value baselineEtag = const Value.absent(), - Value baselineRawIcs = const Value.absent(), - Value mutationPatchJson = const Value.absent(), - Value mutationPatchSchemaVersion = const Value.absent(), - Value targetComponentKey = const Value.absent(), - Value mutationScope = const Value.absent(), - Value destinationCollectionId = const Value.absent(), - Value destinationCollectionHref = const Value.absent(), - Value destinationMemberHref = const Value.absent(), - Value conflictState = const Value.absent(), - Value conflictSnapshotId = const Value.absent(), - Value retryClassification = const Value.absent(), - Value localTempId = const Value.absent(), - Value dependsOnOpId = const Value.absent(), - required String requestJson, - Value baselineUpdatedUtc = const Value.absent(), - Value baselineRawJson = const Value.absent(), - Value attemptCount = const Value.absent(), - Value nextAttemptAtUtc = const Value.absent(), - Value lastErrorCode = const Value.absent(), - Value lastErrorMessage = const Value.absent(), - Value state = const Value.absent(), - Value lastError = const Value.absent(), - required String createdAtUtc, - required String updatedAtUtc, + required String mode, + required String startedAtUtc, + Value finishedAtUtc = const Value.absent(), + required String status, + Value taskListsSeen = const Value.absent(), + Value tasksSeen = const Value.absent(), + Value pendingOpsApplied = const Value.absent(), + Value errorCode = const Value.absent(), + Value errorMessage = const Value.absent(), Value rowid = const Value.absent(), - }) => PendingOpsCompanion.insert( + }) => SyncRunsCompanion.insert( id: id, accountId: accountId, provider: provider, - entityType: entityType, - operation: operation, - operationType: operationType, - taskListId: taskListId, - taskId: taskId, - calendarSourceId: calendarSourceId, - providerCalendarId: providerCalendarId, - eventId: eventId, - davCollectionId: davCollectionId, - davCollectionHref: davCollectionHref, - davObjectId: davObjectId, - davMemberHref: davMemberHref, - baselineEtag: baselineEtag, - baselineRawIcs: baselineRawIcs, - mutationPatchJson: mutationPatchJson, - mutationPatchSchemaVersion: mutationPatchSchemaVersion, - targetComponentKey: targetComponentKey, - mutationScope: mutationScope, - destinationCollectionId: destinationCollectionId, - destinationCollectionHref: destinationCollectionHref, - destinationMemberHref: destinationMemberHref, - conflictState: conflictState, - conflictSnapshotId: conflictSnapshotId, - retryClassification: retryClassification, - localTempId: localTempId, - dependsOnOpId: dependsOnOpId, - requestJson: requestJson, - baselineUpdatedUtc: baselineUpdatedUtc, - baselineRawJson: baselineRawJson, - attemptCount: attemptCount, - nextAttemptAtUtc: nextAttemptAtUtc, - lastErrorCode: lastErrorCode, - lastErrorMessage: lastErrorMessage, - state: state, - lastError: lastError, - createdAtUtc: createdAtUtc, - updatedAtUtc: updatedAtUtc, + mode: mode, + startedAtUtc: startedAtUtc, + finishedAtUtc: finishedAtUtc, + status: status, + taskListsSeen: taskListsSeen, + tasksSeen: tasksSeen, + pendingOpsApplied: pendingOpsApplied, + errorCode: errorCode, + errorMessage: errorMessage, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( e.readTable(table), - $$PendingOpsTableReferences(db, table, e), + $$SyncRunsTableReferences(db, table, e), ), ) .toList(), - prefetchHooksCallback: - ({ - accountId = false, - davCollectionId = false, - davObjectId = false, - destinationCollectionId = false, - conflictSnapshotId = false, - }) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [], - addJoins: - < - T extends TableManagerState< - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic - > - >(state) { - if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: $$PendingOpsTableReferences - ._accountIdTable(db), - referencedColumn: - $$PendingOpsTableReferences - ._accountIdTable(db) - .id, - ) - as T; - } - if (davCollectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davCollectionId, - referencedTable: $$PendingOpsTableReferences - ._davCollectionIdTable(db), - referencedColumn: - $$PendingOpsTableReferences - ._davCollectionIdTable(db) - .id, - ) - as T; - } - if (davObjectId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davObjectId, - referencedTable: $$PendingOpsTableReferences - ._davObjectIdTable(db), - referencedColumn: - $$PendingOpsTableReferences - ._davObjectIdTable(db) - .id, - ) - as T; - } - if (destinationCollectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: - table.destinationCollectionId, - referencedTable: $$PendingOpsTableReferences - ._destinationCollectionIdTable(db), - referencedColumn: - $$PendingOpsTableReferences - ._destinationCollectionIdTable(db) - .id, - ) - as T; - } - if (conflictSnapshotId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.conflictSnapshotId, - referencedTable: $$PendingOpsTableReferences - ._conflictSnapshotIdTable(db), - referencedColumn: - $$PendingOpsTableReferences - ._conflictSnapshotIdTable(db) - .id, - ) - as T; - } + prefetchHooksCallback: ({accountId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (accountId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: $$SyncRunsTableReferences + ._accountIdTable(db), + referencedColumn: $$SyncRunsTableReferences + ._accountIdTable(db) + .id, + ) + as T; + } - return state; - }, - getPrefetchedDataCallback: (items) async { - return []; + return state; }, - ); + getPrefetchedDataCallback: (items) async { + return []; }, + ); + }, ), ); } -typedef $$PendingOpsTableProcessedTableManager = +typedef $$SyncRunsTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $PendingOpsTable, - PendingOp, - $$PendingOpsTableFilterComposer, - $$PendingOpsTableOrderingComposer, - $$PendingOpsTableAnnotationComposer, - $$PendingOpsTableCreateCompanionBuilder, - $$PendingOpsTableUpdateCompanionBuilder, - (PendingOp, $$PendingOpsTableReferences), - PendingOp, - PrefetchHooks Function({ - bool accountId, - bool davCollectionId, - bool davObjectId, - bool destinationCollectionId, - bool conflictSnapshotId, - }) + $SyncRunsTable, + SyncRun, + $$SyncRunsTableFilterComposer, + $$SyncRunsTableOrderingComposer, + $$SyncRunsTableAnnotationComposer, + $$SyncRunsTableCreateCompanionBuilder, + $$SyncRunsTableUpdateCompanionBuilder, + (SyncRun, $$SyncRunsTableReferences), + SyncRun, + PrefetchHooks Function({bool accountId}) >; -typedef $$SyncRunsTableCreateCompanionBuilder = - SyncRunsCompanion Function({ +typedef $$CalendarSourcesTableCreateCompanionBuilder = + CalendarSourcesCompanion Function({ required String id, required String accountId, - Value provider, - required String mode, - required String startedAtUtc, - Value finishedAtUtc, - required String status, - Value taskListsSeen, - Value tasksSeen, - Value pendingOpsApplied, - Value errorCode, - Value errorMessage, + required String provider, + required String providerCalendarId, + Value davCollectionId, + required String summary, + Value description, + Value primaryCalendar, + Value selected, + Value remindersEnabled, + Value hidden, + Value readOnly, + Value backgroundColor, + Value foregroundColor, + Value colorId, + Value timeZone, + Value accessRole, + Value dataOwner, + Value isRemovable, + Value isDeleted, + Value rawJson, + required int createdAtLocal, + required int updatedAtLocal, Value rowid, }); -typedef $$SyncRunsTableUpdateCompanionBuilder = - SyncRunsCompanion Function({ +typedef $$CalendarSourcesTableUpdateCompanionBuilder = + CalendarSourcesCompanion Function({ Value id, Value accountId, - Value provider, - Value mode, - Value startedAtUtc, - Value finishedAtUtc, - Value status, - Value taskListsSeen, - Value tasksSeen, - Value pendingOpsApplied, - Value errorCode, - Value errorMessage, + Value provider, + Value providerCalendarId, + Value davCollectionId, + Value summary, + Value description, + Value primaryCalendar, + Value selected, + Value remindersEnabled, + Value hidden, + Value readOnly, + Value backgroundColor, + Value foregroundColor, + Value colorId, + Value timeZone, + Value accessRole, + Value dataOwner, + Value isRemovable, + Value isDeleted, + Value rawJson, + Value createdAtLocal, + Value updatedAtLocal, Value rowid, }); -final class $$SyncRunsTableReferences - extends BaseReferences<_$AppDatabase, $SyncRunsTable, SyncRun> { - $$SyncRunsTableReferences(super.$_db, super.$_table, super.$_typedResult); +final class $$CalendarSourcesTableReferences + extends + BaseReferences<_$AppDatabase, $CalendarSourcesTable, CalendarSource> { + $$CalendarSourcesTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); - static $AccountsTable _accountIdTable(_$AppDatabase db) => db.accounts - .createAlias($_aliasNameGenerator(db.syncRuns.accountId, db.accounts.id)); + static $AccountsTable _accountIdTable(_$AppDatabase db) => + db.accounts.createAlias( + $_aliasNameGenerator(db.calendarSources.accountId, db.accounts.id), + ); + + $$AccountsTableProcessedTableManager get accountId { + final $_column = $_itemColumn('account_id')!; + + final manager = $$AccountsTableTableManager( + $_db, + $_db.accounts, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => + db.davCollections.createAlias( + $_aliasNameGenerator( + db.calendarSources.davCollectionId, + db.davCollections.id, + ), + ); + + $$DavCollectionsTableProcessedTableManager? get davCollectionId { + final $_column = $_itemColumn('dav_collection_id'); + if ($_column == null) return null; + final manager = $$DavCollectionsTableTableManager( + $_db, + $_db.davCollections, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_davCollectionIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static MultiTypedResultKey<$CalendarEventsTable, List> + _calendarEventsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.calendarEvents, + aliasName: $_aliasNameGenerator( + db.calendarSources.id, + db.calendarEvents.calendarSourceId, + ), + ); + + $$CalendarEventsTableProcessedTableManager get calendarEventsRefs { + final manager = $$CalendarEventsTableTableManager($_db, $_db.calendarEvents) + .filter( + (f) => f.calendarSourceId.id.sqlEquals($_itemColumn('id')!), + ); + + final cache = $_typedResult.readTableOrNull(_calendarEventsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey<$IcalImportReceiptsTable, List> + _icalImportReceiptsRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.icalImportReceipts, + aliasName: $_aliasNameGenerator( + db.calendarSources.id, + db.icalImportReceipts.calendarSourceId, + ), + ); + + $$IcalImportReceiptsTableProcessedTableManager get icalImportReceiptsRefs { + final manager = + $$IcalImportReceiptsTableTableManager( + $_db, + $_db.icalImportReceipts, + ).filter( + (f) => f.calendarSourceId.id.sqlEquals($_itemColumn('id')!), + ); + + final cache = $_typedResult.readTableOrNull( + _icalImportReceiptsRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey< + $WebCalSubscriptionsTable, + List + > + _webCalSubscriptionsRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.webCalSubscriptions, + aliasName: $_aliasNameGenerator( + db.calendarSources.id, + db.webCalSubscriptions.calendarSourceId, + ), + ); + + $$WebCalSubscriptionsTableProcessedTableManager get webCalSubscriptionsRefs { + final manager = + $$WebCalSubscriptionsTableTableManager( + $_db, + $_db.webCalSubscriptions, + ).filter( + (f) => f.calendarSourceId.id.sqlEquals($_itemColumn('id')!), + ); + + final cache = $_typedResult.readTableOrNull( + _webCalSubscriptionsRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey<$SyncCursorsTable, List> + _syncCursorsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.syncCursors, + aliasName: $_aliasNameGenerator( + db.calendarSources.id, + db.syncCursors.projectionSourceId, + ), + ); + + $$SyncCursorsTableProcessedTableManager get syncCursorsRefs { + final manager = $$SyncCursorsTableTableManager($_db, $_db.syncCursors) + .filter( + (f) => f.projectionSourceId.id.sqlEquals($_itemColumn('id')!), + ); + + final cache = $_typedResult.readTableOrNull(_syncCursorsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $$CalendarSourcesTableFilterComposer + extends Composer<_$AppDatabase, $CalendarSourcesTable> { + $$CalendarSourcesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get provider => $composableBuilder( + column: $table.provider, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get providerCalendarId => $composableBuilder( + column: $table.providerCalendarId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get summary => $composableBuilder( + column: $table.summary, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get primaryCalendar => $composableBuilder( + column: $table.primaryCalendar, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get selected => $composableBuilder( + column: $table.selected, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get remindersEnabled => $composableBuilder( + column: $table.remindersEnabled, + builder: (column) => ColumnFilters(column), + ); - $$AccountsTableProcessedTableManager get accountId { - final $_column = $_itemColumn('account_id')!; + ColumnFilters get hidden => $composableBuilder( + column: $table.hidden, + builder: (column) => ColumnFilters(column), + ); - final manager = $$AccountsTableTableManager( - $_db, - $_db.accounts, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } -} + ColumnFilters get readOnly => $composableBuilder( + column: $table.readOnly, + builder: (column) => ColumnFilters(column), + ); -class $$SyncRunsTableFilterComposer - extends Composer<_$AppDatabase, $SyncRunsTable> { - $$SyncRunsTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, + ColumnFilters get backgroundColor => $composableBuilder( + column: $table.backgroundColor, builder: (column) => ColumnFilters(column), ); - ColumnFilters get provider => $composableBuilder( - column: $table.provider, + ColumnFilters get foregroundColor => $composableBuilder( + column: $table.foregroundColor, builder: (column) => ColumnFilters(column), ); - ColumnFilters get mode => $composableBuilder( - column: $table.mode, + ColumnFilters get colorId => $composableBuilder( + column: $table.colorId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get startedAtUtc => $composableBuilder( - column: $table.startedAtUtc, + ColumnFilters get timeZone => $composableBuilder( + column: $table.timeZone, builder: (column) => ColumnFilters(column), ); - ColumnFilters get finishedAtUtc => $composableBuilder( - column: $table.finishedAtUtc, + ColumnFilters get accessRole => $composableBuilder( + column: $table.accessRole, builder: (column) => ColumnFilters(column), ); - ColumnFilters get status => $composableBuilder( - column: $table.status, + ColumnFilters get dataOwner => $composableBuilder( + column: $table.dataOwner, builder: (column) => ColumnFilters(column), ); - ColumnFilters get taskListsSeen => $composableBuilder( - column: $table.taskListsSeen, + ColumnFilters get isRemovable => $composableBuilder( + column: $table.isRemovable, builder: (column) => ColumnFilters(column), ); - ColumnFilters get tasksSeen => $composableBuilder( - column: $table.tasksSeen, + ColumnFilters get isDeleted => $composableBuilder( + column: $table.isDeleted, builder: (column) => ColumnFilters(column), ); - ColumnFilters get pendingOpsApplied => $composableBuilder( - column: $table.pendingOpsApplied, + ColumnFilters get rawJson => $composableBuilder( + column: $table.rawJson, builder: (column) => ColumnFilters(column), ); - ColumnFilters get errorCode => $composableBuilder( - column: $table.errorCode, + ColumnFilters get createdAtLocal => $composableBuilder( + column: $table.createdAtLocal, builder: (column) => ColumnFilters(column), ); - ColumnFilters get errorMessage => $composableBuilder( - column: $table.errorMessage, + ColumnFilters get updatedAtLocal => $composableBuilder( + column: $table.updatedAtLocal, builder: (column) => ColumnFilters(column), ); @@ -33717,11 +36715,134 @@ class $$SyncRunsTableFilterComposer ); return composer; } + + $$DavCollectionsTableFilterComposer get davCollectionId { + final $$DavCollectionsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavCollectionsTableFilterComposer( + $db: $db, + $table: $db.davCollections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + Expression calendarEventsRefs( + Expression Function($$CalendarEventsTableFilterComposer f) f, + ) { + final $$CalendarEventsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarEvents, + getReferencedColumn: (t) => t.calendarSourceId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarEventsTableFilterComposer( + $db: $db, + $table: $db.calendarEvents, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression icalImportReceiptsRefs( + Expression Function($$IcalImportReceiptsTableFilterComposer f) f, + ) { + final $$IcalImportReceiptsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.icalImportReceipts, + getReferencedColumn: (t) => t.calendarSourceId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$IcalImportReceiptsTableFilterComposer( + $db: $db, + $table: $db.icalImportReceipts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression webCalSubscriptionsRefs( + Expression Function($$WebCalSubscriptionsTableFilterComposer f) f, + ) { + final $$WebCalSubscriptionsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.webCalSubscriptions, + getReferencedColumn: (t) => t.calendarSourceId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WebCalSubscriptionsTableFilterComposer( + $db: $db, + $table: $db.webCalSubscriptions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression syncCursorsRefs( + Expression Function($$SyncCursorsTableFilterComposer f) f, + ) { + final $$SyncCursorsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.syncCursors, + getReferencedColumn: (t) => t.projectionSourceId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$SyncCursorsTableFilterComposer( + $db: $db, + $table: $db.syncCursors, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } } -class $$SyncRunsTableOrderingComposer - extends Composer<_$AppDatabase, $SyncRunsTable> { - $$SyncRunsTableOrderingComposer({ +class $$CalendarSourcesTableOrderingComposer + extends Composer<_$AppDatabase, $CalendarSourcesTable> { + $$CalendarSourcesTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -33738,48 +36859,98 @@ class $$SyncRunsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get mode => $composableBuilder( - column: $table.mode, + ColumnOrderings get providerCalendarId => $composableBuilder( + column: $table.providerCalendarId, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get startedAtUtc => $composableBuilder( - column: $table.startedAtUtc, + ColumnOrderings get summary => $composableBuilder( + column: $table.summary, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get finishedAtUtc => $composableBuilder( - column: $table.finishedAtUtc, + ColumnOrderings get description => $composableBuilder( + column: $table.description, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get status => $composableBuilder( - column: $table.status, + ColumnOrderings get primaryCalendar => $composableBuilder( + column: $table.primaryCalendar, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get taskListsSeen => $composableBuilder( - column: $table.taskListsSeen, + ColumnOrderings get selected => $composableBuilder( + column: $table.selected, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get tasksSeen => $composableBuilder( - column: $table.tasksSeen, + ColumnOrderings get remindersEnabled => $composableBuilder( + column: $table.remindersEnabled, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get pendingOpsApplied => $composableBuilder( - column: $table.pendingOpsApplied, + ColumnOrderings get hidden => $composableBuilder( + column: $table.hidden, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get errorCode => $composableBuilder( - column: $table.errorCode, + ColumnOrderings get readOnly => $composableBuilder( + column: $table.readOnly, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get errorMessage => $composableBuilder( - column: $table.errorMessage, + ColumnOrderings get backgroundColor => $composableBuilder( + column: $table.backgroundColor, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get foregroundColor => $composableBuilder( + column: $table.foregroundColor, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get colorId => $composableBuilder( + column: $table.colorId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get timeZone => $composableBuilder( + column: $table.timeZone, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get accessRole => $composableBuilder( + column: $table.accessRole, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get dataOwner => $composableBuilder( + column: $table.dataOwner, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isRemovable => $composableBuilder( + column: $table.isRemovable, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isDeleted => $composableBuilder( + column: $table.isDeleted, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get rawJson => $composableBuilder( + column: $table.rawJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAtLocal => $composableBuilder( + column: $table.createdAtLocal, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAtLocal => $composableBuilder( + column: $table.updatedAtLocal, builder: (column) => ColumnOrderings(column), ); @@ -33805,11 +36976,34 @@ class $$SyncRunsTableOrderingComposer ); return composer; } + + $$DavCollectionsTableOrderingComposer get davCollectionId { + final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavCollectionsTableOrderingComposer( + $db: $db, + $table: $db.davCollections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } } -class $$SyncRunsTableAnnotationComposer - extends Composer<_$AppDatabase, $SyncRunsTable> { - $$SyncRunsTableAnnotationComposer({ +class $$CalendarSourcesTableAnnotationComposer + extends Composer<_$AppDatabase, $CalendarSourcesTable> { + $$CalendarSourcesTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -33822,40 +37016,80 @@ class $$SyncRunsTableAnnotationComposer GeneratedColumn get provider => $composableBuilder(column: $table.provider, builder: (column) => column); - GeneratedColumn get mode => - $composableBuilder(column: $table.mode, builder: (column) => column); + GeneratedColumn get providerCalendarId => $composableBuilder( + column: $table.providerCalendarId, + builder: (column) => column, + ); - GeneratedColumn get startedAtUtc => $composableBuilder( - column: $table.startedAtUtc, + GeneratedColumn get summary => + $composableBuilder(column: $table.summary, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, builder: (column) => column, ); - GeneratedColumn get finishedAtUtc => $composableBuilder( - column: $table.finishedAtUtc, + GeneratedColumn get primaryCalendar => $composableBuilder( + column: $table.primaryCalendar, builder: (column) => column, ); - GeneratedColumn get status => - $composableBuilder(column: $table.status, builder: (column) => column); + GeneratedColumn get selected => + $composableBuilder(column: $table.selected, builder: (column) => column); - GeneratedColumn get taskListsSeen => $composableBuilder( - column: $table.taskListsSeen, + GeneratedColumn get remindersEnabled => $composableBuilder( + column: $table.remindersEnabled, builder: (column) => column, ); - GeneratedColumn get tasksSeen => - $composableBuilder(column: $table.tasksSeen, builder: (column) => column); + GeneratedColumn get hidden => + $composableBuilder(column: $table.hidden, builder: (column) => column); - GeneratedColumn get pendingOpsApplied => $composableBuilder( - column: $table.pendingOpsApplied, + GeneratedColumn get readOnly => + $composableBuilder(column: $table.readOnly, builder: (column) => column); + + GeneratedColumn get backgroundColor => $composableBuilder( + column: $table.backgroundColor, builder: (column) => column, ); - GeneratedColumn get errorCode => - $composableBuilder(column: $table.errorCode, builder: (column) => column); + GeneratedColumn get foregroundColor => $composableBuilder( + column: $table.foregroundColor, + builder: (column) => column, + ); - GeneratedColumn get errorMessage => $composableBuilder( - column: $table.errorMessage, + GeneratedColumn get colorId => + $composableBuilder(column: $table.colorId, builder: (column) => column); + + GeneratedColumn get timeZone => + $composableBuilder(column: $table.timeZone, builder: (column) => column); + + GeneratedColumn get accessRole => $composableBuilder( + column: $table.accessRole, + builder: (column) => column, + ); + + GeneratedColumn get dataOwner => + $composableBuilder(column: $table.dataOwner, builder: (column) => column); + + GeneratedColumn get isRemovable => $composableBuilder( + column: $table.isRemovable, + builder: (column) => column, + ); + + GeneratedColumn get isDeleted => + $composableBuilder(column: $table.isDeleted, builder: (column) => column); + + GeneratedColumn get rawJson => + $composableBuilder(column: $table.rawJson, builder: (column) => column); + + GeneratedColumn get createdAtLocal => $composableBuilder( + column: $table.createdAtLocal, + builder: (column) => column, + ); + + GeneratedColumn get updatedAtLocal => $composableBuilder( + column: $table.updatedAtLocal, builder: (column) => column, ); @@ -33881,308 +37115,748 @@ class $$SyncRunsTableAnnotationComposer ); return composer; } + + $$DavCollectionsTableAnnotationComposer get davCollectionId { + final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavCollectionsTableAnnotationComposer( + $db: $db, + $table: $db.davCollections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + Expression calendarEventsRefs( + Expression Function($$CalendarEventsTableAnnotationComposer a) f, + ) { + final $$CalendarEventsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarEvents, + getReferencedColumn: (t) => t.calendarSourceId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarEventsTableAnnotationComposer( + $db: $db, + $table: $db.calendarEvents, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression icalImportReceiptsRefs( + Expression Function($$IcalImportReceiptsTableAnnotationComposer a) f, + ) { + final $$IcalImportReceiptsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.icalImportReceipts, + getReferencedColumn: (t) => t.calendarSourceId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$IcalImportReceiptsTableAnnotationComposer( + $db: $db, + $table: $db.icalImportReceipts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression webCalSubscriptionsRefs( + Expression Function($$WebCalSubscriptionsTableAnnotationComposer a) f, + ) { + final $$WebCalSubscriptionsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.webCalSubscriptions, + getReferencedColumn: (t) => t.calendarSourceId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WebCalSubscriptionsTableAnnotationComposer( + $db: $db, + $table: $db.webCalSubscriptions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression syncCursorsRefs( + Expression Function($$SyncCursorsTableAnnotationComposer a) f, + ) { + final $$SyncCursorsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.syncCursors, + getReferencedColumn: (t) => t.projectionSourceId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$SyncCursorsTableAnnotationComposer( + $db: $db, + $table: $db.syncCursors, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } } -class $$SyncRunsTableTableManager +class $$CalendarSourcesTableTableManager extends RootTableManager< _$AppDatabase, - $SyncRunsTable, - SyncRun, - $$SyncRunsTableFilterComposer, - $$SyncRunsTableOrderingComposer, - $$SyncRunsTableAnnotationComposer, - $$SyncRunsTableCreateCompanionBuilder, - $$SyncRunsTableUpdateCompanionBuilder, - (SyncRun, $$SyncRunsTableReferences), - SyncRun, - PrefetchHooks Function({bool accountId}) + $CalendarSourcesTable, + CalendarSource, + $$CalendarSourcesTableFilterComposer, + $$CalendarSourcesTableOrderingComposer, + $$CalendarSourcesTableAnnotationComposer, + $$CalendarSourcesTableCreateCompanionBuilder, + $$CalendarSourcesTableUpdateCompanionBuilder, + (CalendarSource, $$CalendarSourcesTableReferences), + CalendarSource, + PrefetchHooks Function({ + bool accountId, + bool davCollectionId, + bool calendarEventsRefs, + bool icalImportReceiptsRefs, + bool webCalSubscriptionsRefs, + bool syncCursorsRefs, + }) > { - $$SyncRunsTableTableManager(_$AppDatabase db, $SyncRunsTable table) - : super( + $$CalendarSourcesTableTableManager( + _$AppDatabase db, + $CalendarSourcesTable table, + ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$SyncRunsTableFilterComposer($db: db, $table: table), + $$CalendarSourcesTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$SyncRunsTableOrderingComposer($db: db, $table: table), + $$CalendarSourcesTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$SyncRunsTableAnnotationComposer($db: db, $table: table), + $$CalendarSourcesTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), Value accountId = const Value.absent(), - Value provider = const Value.absent(), - Value mode = const Value.absent(), - Value startedAtUtc = const Value.absent(), - Value finishedAtUtc = const Value.absent(), - Value status = const Value.absent(), - Value taskListsSeen = const Value.absent(), - Value tasksSeen = const Value.absent(), - Value pendingOpsApplied = const Value.absent(), - Value errorCode = const Value.absent(), - Value errorMessage = const Value.absent(), + Value provider = const Value.absent(), + Value providerCalendarId = const Value.absent(), + Value davCollectionId = const Value.absent(), + Value summary = const Value.absent(), + Value description = const Value.absent(), + Value primaryCalendar = const Value.absent(), + Value selected = const Value.absent(), + Value remindersEnabled = const Value.absent(), + Value hidden = const Value.absent(), + Value readOnly = const Value.absent(), + Value backgroundColor = const Value.absent(), + Value foregroundColor = const Value.absent(), + Value colorId = const Value.absent(), + Value timeZone = const Value.absent(), + Value accessRole = const Value.absent(), + Value dataOwner = const Value.absent(), + Value isRemovable = const Value.absent(), + Value isDeleted = const Value.absent(), + Value rawJson = const Value.absent(), + Value createdAtLocal = const Value.absent(), + Value updatedAtLocal = const Value.absent(), Value rowid = const Value.absent(), - }) => SyncRunsCompanion( + }) => CalendarSourcesCompanion( id: id, accountId: accountId, provider: provider, - mode: mode, - startedAtUtc: startedAtUtc, - finishedAtUtc: finishedAtUtc, - status: status, - taskListsSeen: taskListsSeen, - tasksSeen: tasksSeen, - pendingOpsApplied: pendingOpsApplied, - errorCode: errorCode, - errorMessage: errorMessage, + providerCalendarId: providerCalendarId, + davCollectionId: davCollectionId, + summary: summary, + description: description, + primaryCalendar: primaryCalendar, + selected: selected, + remindersEnabled: remindersEnabled, + hidden: hidden, + readOnly: readOnly, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + colorId: colorId, + timeZone: timeZone, + accessRole: accessRole, + dataOwner: dataOwner, + isRemovable: isRemovable, + isDeleted: isDeleted, + rawJson: rawJson, + createdAtLocal: createdAtLocal, + updatedAtLocal: updatedAtLocal, rowid: rowid, ), createCompanionCallback: ({ required String id, required String accountId, - Value provider = const Value.absent(), - required String mode, - required String startedAtUtc, - Value finishedAtUtc = const Value.absent(), - required String status, - Value taskListsSeen = const Value.absent(), - Value tasksSeen = const Value.absent(), - Value pendingOpsApplied = const Value.absent(), - Value errorCode = const Value.absent(), - Value errorMessage = const Value.absent(), + required String provider, + required String providerCalendarId, + Value davCollectionId = const Value.absent(), + required String summary, + Value description = const Value.absent(), + Value primaryCalendar = const Value.absent(), + Value selected = const Value.absent(), + Value remindersEnabled = const Value.absent(), + Value hidden = const Value.absent(), + Value readOnly = const Value.absent(), + Value backgroundColor = const Value.absent(), + Value foregroundColor = const Value.absent(), + Value colorId = const Value.absent(), + Value timeZone = const Value.absent(), + Value accessRole = const Value.absent(), + Value dataOwner = const Value.absent(), + Value isRemovable = const Value.absent(), + Value isDeleted = const Value.absent(), + Value rawJson = const Value.absent(), + required int createdAtLocal, + required int updatedAtLocal, Value rowid = const Value.absent(), - }) => SyncRunsCompanion.insert( + }) => CalendarSourcesCompanion.insert( id: id, accountId: accountId, provider: provider, - mode: mode, - startedAtUtc: startedAtUtc, - finishedAtUtc: finishedAtUtc, - status: status, - taskListsSeen: taskListsSeen, - tasksSeen: tasksSeen, - pendingOpsApplied: pendingOpsApplied, - errorCode: errorCode, - errorMessage: errorMessage, + providerCalendarId: providerCalendarId, + davCollectionId: davCollectionId, + summary: summary, + description: description, + primaryCalendar: primaryCalendar, + selected: selected, + remindersEnabled: remindersEnabled, + hidden: hidden, + readOnly: readOnly, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + colorId: colorId, + timeZone: timeZone, + accessRole: accessRole, + dataOwner: dataOwner, + isRemovable: isRemovable, + isDeleted: isDeleted, + rawJson: rawJson, + createdAtLocal: createdAtLocal, + updatedAtLocal: updatedAtLocal, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( e.readTable(table), - $$SyncRunsTableReferences(db, table, e), + $$CalendarSourcesTableReferences(db, table, e), ), ) .toList(), - prefetchHooksCallback: ({accountId = false}) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [], - addJoins: - < - T extends TableManagerState< - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic - > - >(state) { - if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: $$SyncRunsTableReferences - ._accountIdTable(db), - referencedColumn: $$SyncRunsTableReferences - ._accountIdTable(db) - .id, - ) - as T; - } + prefetchHooksCallback: + ({ + accountId = false, + davCollectionId = false, + calendarEventsRefs = false, + icalImportReceiptsRefs = false, + webCalSubscriptionsRefs = false, + syncCursorsRefs = false, + }) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (calendarEventsRefs) db.calendarEvents, + if (icalImportReceiptsRefs) db.icalImportReceipts, + if (webCalSubscriptionsRefs) db.webCalSubscriptions, + if (syncCursorsRefs) db.syncCursors, + ], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (accountId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: + $$CalendarSourcesTableReferences + ._accountIdTable(db), + referencedColumn: + $$CalendarSourcesTableReferences + ._accountIdTable(db) + .id, + ) + as T; + } + if (davCollectionId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.davCollectionId, + referencedTable: + $$CalendarSourcesTableReferences + ._davCollectionIdTable(db), + referencedColumn: + $$CalendarSourcesTableReferences + ._davCollectionIdTable(db) + .id, + ) + as T; + } - return state; + return state; + }, + getPrefetchedDataCallback: (items) async { + return [ + if (calendarEventsRefs) + await $_getPrefetchedData< + CalendarSource, + $CalendarSourcesTable, + CalendarEvent + >( + currentTable: table, + referencedTable: $$CalendarSourcesTableReferences + ._calendarEventsRefsTable(db), + managerFromTypedResult: (p0) => + $$CalendarSourcesTableReferences( + db, + table, + p0, + ).calendarEventsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.calendarSourceId == item.id, + ), + typedResults: items, + ), + if (icalImportReceiptsRefs) + await $_getPrefetchedData< + CalendarSource, + $CalendarSourcesTable, + IcalImportReceipt + >( + currentTable: table, + referencedTable: $$CalendarSourcesTableReferences + ._icalImportReceiptsRefsTable(db), + managerFromTypedResult: (p0) => + $$CalendarSourcesTableReferences( + db, + table, + p0, + ).icalImportReceiptsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.calendarSourceId == item.id, + ), + typedResults: items, + ), + if (webCalSubscriptionsRefs) + await $_getPrefetchedData< + CalendarSource, + $CalendarSourcesTable, + WebCalSubscription + >( + currentTable: table, + referencedTable: $$CalendarSourcesTableReferences + ._webCalSubscriptionsRefsTable(db), + managerFromTypedResult: (p0) => + $$CalendarSourcesTableReferences( + db, + table, + p0, + ).webCalSubscriptionsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.calendarSourceId == item.id, + ), + typedResults: items, + ), + if (syncCursorsRefs) + await $_getPrefetchedData< + CalendarSource, + $CalendarSourcesTable, + SyncCursor + >( + currentTable: table, + referencedTable: $$CalendarSourcesTableReferences + ._syncCursorsRefsTable(db), + managerFromTypedResult: (p0) => + $$CalendarSourcesTableReferences( + db, + table, + p0, + ).syncCursorsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.projectionSourceId == item.id, + ), + typedResults: items, + ), + ]; }, - getPrefetchedDataCallback: (items) async { - return []; + ); }, - ); - }, ), ); } -typedef $$SyncRunsTableProcessedTableManager = +typedef $$CalendarSourcesTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $SyncRunsTable, - SyncRun, - $$SyncRunsTableFilterComposer, - $$SyncRunsTableOrderingComposer, - $$SyncRunsTableAnnotationComposer, - $$SyncRunsTableCreateCompanionBuilder, - $$SyncRunsTableUpdateCompanionBuilder, - (SyncRun, $$SyncRunsTableReferences), - SyncRun, - PrefetchHooks Function({bool accountId}) + $CalendarSourcesTable, + CalendarSource, + $$CalendarSourcesTableFilterComposer, + $$CalendarSourcesTableOrderingComposer, + $$CalendarSourcesTableAnnotationComposer, + $$CalendarSourcesTableCreateCompanionBuilder, + $$CalendarSourcesTableUpdateCompanionBuilder, + (CalendarSource, $$CalendarSourcesTableReferences), + CalendarSource, + PrefetchHooks Function({ + bool accountId, + bool davCollectionId, + bool calendarEventsRefs, + bool icalImportReceiptsRefs, + bool webCalSubscriptionsRefs, + bool syncCursorsRefs, + }) >; -typedef $$CalendarSourcesTableCreateCompanionBuilder = - CalendarSourcesCompanion Function({ +typedef $$CalendarEventsTableCreateCompanionBuilder = + CalendarEventsCompanion Function({ required String id, required String accountId, + required String calendarSourceId, required String provider, required String providerCalendarId, + required String providerEventId, Value davCollectionId, - required String summary, + Value davObjectId, + Value davComponentId, + Value icalUid, + Value recurrenceIdKey, + Value occurrenceKey, + Value projectionVersion, + Value providerRecurringEventId, + Value providerOriginalStartKey, + Value etagOrChangeKey, + Value status, + required String title, Value description, - Value primaryCalendar, - Value selected, - Value remindersEnabled, - Value hidden, - Value readOnly, - Value backgroundColor, - Value foregroundColor, + Value location, + Value allDay, + Value startDate, + Value startDateTime, + Value startTimeZone, + Value endDate, + Value endDateTime, + Value endTimeZone, + Value recurrenceJson, + Value remindersJson, + Value attendeesJson, + Value categoriesJson, + Value organizerJson, + Value creatorJson, Value colorId, - Value timeZone, - Value accessRole, + Value colorHex, + Value visibility, + Value transparencyOrShowAs, + Value eventType, + Value webLink, + Value conferenceJson, + Value attachmentsJson, + Value isCancelled, Value isDeleted, Value rawJson, + Value createdAtServer, + Value updatedAtServer, required int createdAtLocal, required int updatedAtLocal, + Value syncStatus, + Value baselineRawJson, Value rowid, }); -typedef $$CalendarSourcesTableUpdateCompanionBuilder = - CalendarSourcesCompanion Function({ +typedef $$CalendarEventsTableUpdateCompanionBuilder = + CalendarEventsCompanion Function({ Value id, Value accountId, + Value calendarSourceId, Value provider, Value providerCalendarId, + Value providerEventId, Value davCollectionId, - Value summary, + Value davObjectId, + Value davComponentId, + Value icalUid, + Value recurrenceIdKey, + Value occurrenceKey, + Value projectionVersion, + Value providerRecurringEventId, + Value providerOriginalStartKey, + Value etagOrChangeKey, + Value status, + Value title, Value description, - Value primaryCalendar, - Value selected, - Value remindersEnabled, - Value hidden, - Value readOnly, - Value backgroundColor, - Value foregroundColor, + Value location, + Value allDay, + Value startDate, + Value startDateTime, + Value startTimeZone, + Value endDate, + Value endDateTime, + Value endTimeZone, + Value recurrenceJson, + Value remindersJson, + Value attendeesJson, + Value categoriesJson, + Value organizerJson, + Value creatorJson, Value colorId, - Value timeZone, - Value accessRole, + Value colorHex, + Value visibility, + Value transparencyOrShowAs, + Value eventType, + Value webLink, + Value conferenceJson, + Value attachmentsJson, + Value isCancelled, Value isDeleted, Value rawJson, + Value createdAtServer, + Value updatedAtServer, Value createdAtLocal, Value updatedAtLocal, + Value syncStatus, + Value baselineRawJson, Value rowid, }); -final class $$CalendarSourcesTableReferences - extends - BaseReferences<_$AppDatabase, $CalendarSourcesTable, CalendarSource> { - $$CalendarSourcesTableReferences( +final class $$CalendarEventsTableReferences + extends BaseReferences<_$AppDatabase, $CalendarEventsTable, CalendarEvent> { + $$CalendarEventsTableReferences( super.$_db, super.$_table, super.$_typedResult, ); - static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.calendarSources.accountId, db.accounts.id), + static $AccountsTable _accountIdTable(_$AppDatabase db) => + db.accounts.createAlias( + $_aliasNameGenerator(db.calendarEvents.accountId, db.accounts.id), + ); + + $$AccountsTableProcessedTableManager get accountId { + final $_column = $_itemColumn('account_id')!; + + final manager = $$AccountsTableTableManager( + $_db, + $_db.accounts, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $CalendarSourcesTable _calendarSourceIdTable(_$AppDatabase db) => + db.calendarSources.createAlias( + $_aliasNameGenerator( + db.calendarEvents.calendarSourceId, + db.calendarSources.id, + ), + ); + + $$CalendarSourcesTableProcessedTableManager get calendarSourceId { + final $_column = $_itemColumn('calendar_source_id')!; + + final manager = $$CalendarSourcesTableTableManager( + $_db, + $_db.calendarSources, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_calendarSourceIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => + db.davCollections.createAlias( + $_aliasNameGenerator( + db.calendarEvents.davCollectionId, + db.davCollections.id, + ), + ); + + $$DavCollectionsTableProcessedTableManager? get davCollectionId { + final $_column = $_itemColumn('dav_collection_id'); + if ($_column == null) return null; + final manager = $$DavCollectionsTableTableManager( + $_db, + $_db.davCollections, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_davCollectionIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => + db.davObjects.createAlias( + $_aliasNameGenerator(db.calendarEvents.davObjectId, db.davObjects.id), ); - $$AccountsTableProcessedTableManager get accountId { - final $_column = $_itemColumn('account_id')!; - - final manager = $$AccountsTableTableManager( + $$DavObjectsTableProcessedTableManager? get davObjectId { + final $_column = $_itemColumn('dav_object_id'); + if ($_column == null) return null; + final manager = $$DavObjectsTableTableManager( $_db, - $_db.accounts, + $_db.davObjects, ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); + final item = $_typedResult.readTableOrNull(_davObjectIdTable($_db)); if (item == null) return manager; return ProcessedTableManager( manager.$state.copyWith(prefetchedData: [item]), ); } - static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => - db.davCollections.createAlias( + static $DavObjectComponentsTable _davComponentIdTable(_$AppDatabase db) => + db.davObjectComponents.createAlias( $_aliasNameGenerator( - db.calendarSources.davCollectionId, - db.davCollections.id, + db.calendarEvents.davComponentId, + db.davObjectComponents.id, ), ); - $$DavCollectionsTableProcessedTableManager? get davCollectionId { - final $_column = $_itemColumn('dav_collection_id'); + $$DavObjectComponentsTableProcessedTableManager? get davComponentId { + final $_column = $_itemColumn('dav_component_id'); if ($_column == null) return null; - final manager = $$DavCollectionsTableTableManager( + final manager = $$DavObjectComponentsTableTableManager( $_db, - $_db.davCollections, + $_db.davObjectComponents, ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_davCollectionIdTable($_db)); + final item = $_typedResult.readTableOrNull(_davComponentIdTable($_db)); if (item == null) return manager; return ProcessedTableManager( manager.$state.copyWith(prefetchedData: [item]), ); } - static MultiTypedResultKey<$CalendarEventsTable, List> - _calendarEventsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.calendarEvents, - aliasName: $_aliasNameGenerator( - db.calendarSources.id, - db.calendarEvents.calendarSourceId, - ), - ); + static MultiTypedResultKey< + $CalendarEventAttendeesTable, + List + > + _calendarEventAttendeesRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.calendarEventAttendees, + aliasName: $_aliasNameGenerator( + db.calendarEvents.id, + db.calendarEventAttendees.calendarEventId, + ), + ); - $$CalendarEventsTableProcessedTableManager get calendarEventsRefs { - final manager = $$CalendarEventsTableTableManager($_db, $_db.calendarEvents) - .filter( - (f) => f.calendarSourceId.id.sqlEquals($_itemColumn('id')!), + $$CalendarEventAttendeesTableProcessedTableManager + get calendarEventAttendeesRefs { + final manager = + $$CalendarEventAttendeesTableTableManager( + $_db, + $_db.calendarEventAttendees, + ).filter( + (f) => f.calendarEventId.id.sqlEquals($_itemColumn('id')!), ); - final cache = $_typedResult.readTableOrNull(_calendarEventsRefsTable($_db)); + final cache = $_typedResult.readTableOrNull( + _calendarEventAttendeesRefsTable($_db), + ); return ProcessedTableManager( manager.$state.copyWith(prefetchedData: cache), ); } - static MultiTypedResultKey<$SyncCursorsTable, List> - _syncCursorsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.syncCursors, - aliasName: $_aliasNameGenerator( - db.calendarSources.id, - db.syncCursors.projectionSourceId, - ), - ); + static MultiTypedResultKey< + $CalendarEventRemindersTable, + List + > + _calendarEventRemindersRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.calendarEventReminders, + aliasName: $_aliasNameGenerator( + db.calendarEvents.id, + db.calendarEventReminders.calendarEventId, + ), + ); - $$SyncCursorsTableProcessedTableManager get syncCursorsRefs { - final manager = $$SyncCursorsTableTableManager($_db, $_db.syncCursors) - .filter( - (f) => f.projectionSourceId.id.sqlEquals($_itemColumn('id')!), + $$CalendarEventRemindersTableProcessedTableManager + get calendarEventRemindersRefs { + final manager = + $$CalendarEventRemindersTableTableManager( + $_db, + $_db.calendarEventReminders, + ).filter( + (f) => f.calendarEventId.id.sqlEquals($_itemColumn('id')!), ); - final cache = $_typedResult.readTableOrNull(_syncCursorsRefsTable($_db)); + final cache = $_typedResult.readTableOrNull( + _calendarEventRemindersRefsTable($_db), + ); return ProcessedTableManager( manager.$state.copyWith(prefetchedData: cache), ); } } -class $$CalendarSourcesTableFilterComposer - extends Composer<_$AppDatabase, $CalendarSourcesTable> { - $$CalendarSourcesTableFilterComposer({ +class $$CalendarEventsTableFilterComposer + extends Composer<_$AppDatabase, $CalendarEventsTable> { + $$CalendarEventsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -34204,8 +37878,53 @@ class $$CalendarSourcesTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get summary => $composableBuilder( - column: $table.summary, + ColumnFilters get providerEventId => $composableBuilder( + column: $table.providerEventId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get icalUid => $composableBuilder( + column: $table.icalUid, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get recurrenceIdKey => $composableBuilder( + column: $table.recurrenceIdKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get occurrenceKey => $composableBuilder( + column: $table.occurrenceKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get projectionVersion => $composableBuilder( + column: $table.projectionVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get providerRecurringEventId => $composableBuilder( + column: $table.providerRecurringEventId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get providerOriginalStartKey => $composableBuilder( + column: $table.providerOriginalStartKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get etagOrChangeKey => $composableBuilder( + column: $table.etagOrChangeKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, builder: (column) => ColumnFilters(column), ); @@ -34214,38 +37933,73 @@ class $$CalendarSourcesTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get primaryCalendar => $composableBuilder( - column: $table.primaryCalendar, + ColumnFilters get location => $composableBuilder( + column: $table.location, builder: (column) => ColumnFilters(column), ); - ColumnFilters get selected => $composableBuilder( - column: $table.selected, + ColumnFilters get allDay => $composableBuilder( + column: $table.allDay, builder: (column) => ColumnFilters(column), ); - ColumnFilters get remindersEnabled => $composableBuilder( - column: $table.remindersEnabled, + ColumnFilters get startDate => $composableBuilder( + column: $table.startDate, builder: (column) => ColumnFilters(column), ); - ColumnFilters get hidden => $composableBuilder( - column: $table.hidden, + ColumnFilters get startDateTime => $composableBuilder( + column: $table.startDateTime, builder: (column) => ColumnFilters(column), ); - ColumnFilters get readOnly => $composableBuilder( - column: $table.readOnly, + ColumnFilters get startTimeZone => $composableBuilder( + column: $table.startTimeZone, builder: (column) => ColumnFilters(column), ); - ColumnFilters get backgroundColor => $composableBuilder( - column: $table.backgroundColor, + ColumnFilters get endDate => $composableBuilder( + column: $table.endDate, builder: (column) => ColumnFilters(column), ); - ColumnFilters get foregroundColor => $composableBuilder( - column: $table.foregroundColor, + ColumnFilters get endDateTime => $composableBuilder( + column: $table.endDateTime, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get endTimeZone => $composableBuilder( + column: $table.endTimeZone, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get recurrenceJson => $composableBuilder( + column: $table.recurrenceJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get remindersJson => $composableBuilder( + column: $table.remindersJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get attendeesJson => $composableBuilder( + column: $table.attendeesJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get categoriesJson => $composableBuilder( + column: $table.categoriesJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get organizerJson => $composableBuilder( + column: $table.organizerJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get creatorJson => $composableBuilder( + column: $table.creatorJson, builder: (column) => ColumnFilters(column), ); @@ -34254,13 +38008,43 @@ class $$CalendarSourcesTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get timeZone => $composableBuilder( - column: $table.timeZone, + ColumnFilters get colorHex => $composableBuilder( + column: $table.colorHex, builder: (column) => ColumnFilters(column), ); - ColumnFilters get accessRole => $composableBuilder( - column: $table.accessRole, + ColumnFilters get visibility => $composableBuilder( + column: $table.visibility, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get transparencyOrShowAs => $composableBuilder( + column: $table.transparencyOrShowAs, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get eventType => $composableBuilder( + column: $table.eventType, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get webLink => $composableBuilder( + column: $table.webLink, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get conferenceJson => $composableBuilder( + column: $table.conferenceJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get attachmentsJson => $composableBuilder( + column: $table.attachmentsJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isCancelled => $composableBuilder( + column: $table.isCancelled, builder: (column) => ColumnFilters(column), ); @@ -34274,6 +38058,16 @@ class $$CalendarSourcesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get createdAtServer => $composableBuilder( + column: $table.createdAtServer, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAtServer => $composableBuilder( + column: $table.updatedAtServer, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get createdAtLocal => $composableBuilder( column: $table.createdAtLocal, builder: (column) => ColumnFilters(column), @@ -34284,6 +38078,16 @@ class $$CalendarSourcesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get syncStatus => $composableBuilder( + column: $table.syncStatus, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get baselineRawJson => $composableBuilder( + column: $table.baselineRawJson, + builder: (column) => ColumnFilters(column), + ); + $$AccountsTableFilterComposer get accountId { final $$AccountsTableFilterComposer composer = $composerBuilder( composer: this, @@ -34307,6 +38111,29 @@ class $$CalendarSourcesTableFilterComposer return composer; } + $$CalendarSourcesTableFilterComposer get calendarSourceId { + final $$CalendarSourcesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarSourcesTableFilterComposer( + $db: $db, + $table: $db.calendarSources, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + $$DavCollectionsTableFilterComposer get davCollectionId { final $$DavCollectionsTableFilterComposer composer = $composerBuilder( composer: this, @@ -34330,138 +38157,296 @@ class $$CalendarSourcesTableFilterComposer return composer; } - Expression calendarEventsRefs( - Expression Function($$CalendarEventsTableFilterComposer f) f, - ) { - final $$CalendarEventsTableFilterComposer composer = $composerBuilder( + $$DavObjectsTableFilterComposer get davObjectId { + final $$DavObjectsTableFilterComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarEvents, - getReferencedColumn: (t) => t.calendarSourceId, + getCurrentColumn: (t) => t.davObjectId, + referencedTable: $db.davObjects, + getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventsTableFilterComposer( + }) => $$DavObjectsTableFilterComposer( $db: $db, - $table: $db.calendarEvents, + $table: $db.davObjects, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, ), ); - return f(composer); + return composer; } - Expression syncCursorsRefs( - Expression Function($$SyncCursorsTableFilterComposer f) f, - ) { - final $$SyncCursorsTableFilterComposer composer = $composerBuilder( + $$DavObjectComponentsTableFilterComposer get davComponentId { + final $$DavObjectComponentsTableFilterComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.syncCursors, - getReferencedColumn: (t) => t.projectionSourceId, + getCurrentColumn: (t) => t.davComponentId, + referencedTable: $db.davObjectComponents, + getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$SyncCursorsTableFilterComposer( + }) => $$DavObjectComponentsTableFilterComposer( $db: $db, - $table: $db.syncCursors, + $table: $db.davObjectComponents, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, ), ); + return composer; + } + + Expression calendarEventAttendeesRefs( + Expression Function($$CalendarEventAttendeesTableFilterComposer f) f, + ) { + final $$CalendarEventAttendeesTableFilterComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarEventAttendees, + getReferencedColumn: (t) => t.calendarEventId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarEventAttendeesTableFilterComposer( + $db: $db, + $table: $db.calendarEventAttendees, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression calendarEventRemindersRefs( + Expression Function($$CalendarEventRemindersTableFilterComposer f) f, + ) { + final $$CalendarEventRemindersTableFilterComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarEventReminders, + getReferencedColumn: (t) => t.calendarEventId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarEventRemindersTableFilterComposer( + $db: $db, + $table: $db.calendarEventReminders, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } } -class $$CalendarSourcesTableOrderingComposer - extends Composer<_$AppDatabase, $CalendarSourcesTable> { - $$CalendarSourcesTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, +class $$CalendarEventsTableOrderingComposer + extends Composer<_$AppDatabase, $CalendarEventsTable> { + $$CalendarEventsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get provider => $composableBuilder( + column: $table.provider, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get providerCalendarId => $composableBuilder( + column: $table.providerCalendarId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get providerEventId => $composableBuilder( + column: $table.providerEventId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get icalUid => $composableBuilder( + column: $table.icalUid, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get recurrenceIdKey => $composableBuilder( + column: $table.recurrenceIdKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get occurrenceKey => $composableBuilder( + column: $table.occurrenceKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get projectionVersion => $composableBuilder( + column: $table.projectionVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get providerRecurringEventId => $composableBuilder( + column: $table.providerRecurringEventId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get providerOriginalStartKey => $composableBuilder( + column: $table.providerOriginalStartKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get etagOrChangeKey => $composableBuilder( + column: $table.etagOrChangeKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get location => $composableBuilder( + column: $table.location, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get allDay => $composableBuilder( + column: $table.allDay, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get startDate => $composableBuilder( + column: $table.startDate, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get startDateTime => $composableBuilder( + column: $table.startDateTime, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get startTimeZone => $composableBuilder( + column: $table.startTimeZone, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get endDate => $composableBuilder( + column: $table.endDate, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get endDateTime => $composableBuilder( + column: $table.endDateTime, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get endTimeZone => $composableBuilder( + column: $table.endTimeZone, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get recurrenceJson => $composableBuilder( + column: $table.recurrenceJson, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get provider => $composableBuilder( - column: $table.provider, + ColumnOrderings get remindersJson => $composableBuilder( + column: $table.remindersJson, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get providerCalendarId => $composableBuilder( - column: $table.providerCalendarId, + ColumnOrderings get attendeesJson => $composableBuilder( + column: $table.attendeesJson, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get summary => $composableBuilder( - column: $table.summary, + ColumnOrderings get categoriesJson => $composableBuilder( + column: $table.categoriesJson, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get description => $composableBuilder( - column: $table.description, + ColumnOrderings get organizerJson => $composableBuilder( + column: $table.organizerJson, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get primaryCalendar => $composableBuilder( - column: $table.primaryCalendar, + ColumnOrderings get creatorJson => $composableBuilder( + column: $table.creatorJson, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get selected => $composableBuilder( - column: $table.selected, + ColumnOrderings get colorId => $composableBuilder( + column: $table.colorId, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get remindersEnabled => $composableBuilder( - column: $table.remindersEnabled, + ColumnOrderings get colorHex => $composableBuilder( + column: $table.colorHex, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get hidden => $composableBuilder( - column: $table.hidden, + ColumnOrderings get visibility => $composableBuilder( + column: $table.visibility, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get readOnly => $composableBuilder( - column: $table.readOnly, + ColumnOrderings get transparencyOrShowAs => $composableBuilder( + column: $table.transparencyOrShowAs, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get backgroundColor => $composableBuilder( - column: $table.backgroundColor, + ColumnOrderings get eventType => $composableBuilder( + column: $table.eventType, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get foregroundColor => $composableBuilder( - column: $table.foregroundColor, + ColumnOrderings get webLink => $composableBuilder( + column: $table.webLink, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get colorId => $composableBuilder( - column: $table.colorId, + ColumnOrderings get conferenceJson => $composableBuilder( + column: $table.conferenceJson, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get timeZone => $composableBuilder( - column: $table.timeZone, + ColumnOrderings get attachmentsJson => $composableBuilder( + column: $table.attachmentsJson, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get accessRole => $composableBuilder( - column: $table.accessRole, + ColumnOrderings get isCancelled => $composableBuilder( + column: $table.isCancelled, builder: (column) => ColumnOrderings(column), ); @@ -34475,6 +38460,16 @@ class $$CalendarSourcesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get createdAtServer => $composableBuilder( + column: $table.createdAtServer, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAtServer => $composableBuilder( + column: $table.updatedAtServer, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get createdAtLocal => $composableBuilder( column: $table.createdAtLocal, builder: (column) => ColumnOrderings(column), @@ -34485,6 +38480,16 @@ class $$CalendarSourcesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get syncStatus => $composableBuilder( + column: $table.syncStatus, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get baselineRawJson => $composableBuilder( + column: $table.baselineRawJson, + builder: (column) => ColumnOrderings(column), + ); + $$AccountsTableOrderingComposer get accountId { final $$AccountsTableOrderingComposer composer = $composerBuilder( composer: this, @@ -34508,6 +38513,29 @@ class $$CalendarSourcesTableOrderingComposer return composer; } + $$CalendarSourcesTableOrderingComposer get calendarSourceId { + final $$CalendarSourcesTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarSourcesTableOrderingComposer( + $db: $db, + $table: $db.calendarSources, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + $$DavCollectionsTableOrderingComposer get davCollectionId { final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( composer: this, @@ -34530,11 +38558,58 @@ class $$CalendarSourcesTableOrderingComposer ); return composer; } + + $$DavObjectsTableOrderingComposer get davObjectId { + final $$DavObjectsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davObjectId, + referencedTable: $db.davObjects, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectsTableOrderingComposer( + $db: $db, + $table: $db.davObjects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$DavObjectComponentsTableOrderingComposer get davComponentId { + final $$DavObjectComponentsTableOrderingComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davComponentId, + referencedTable: $db.davObjectComponents, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectComponentsTableOrderingComposer( + $db: $db, + $table: $db.davObjectComponents, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } } -class $$CalendarSourcesTableAnnotationComposer - extends Composer<_$AppDatabase, $CalendarSourcesTable> { - $$CalendarSourcesTableAnnotationComposer({ +class $$CalendarEventsTableAnnotationComposer + extends Composer<_$AppDatabase, $CalendarEventsTable> { + $$CalendarEventsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -34552,51 +38627,151 @@ class $$CalendarSourcesTableAnnotationComposer builder: (column) => column, ); - GeneratedColumn get summary => - $composableBuilder(column: $table.summary, builder: (column) => column); + GeneratedColumn get providerEventId => $composableBuilder( + column: $table.providerEventId, + builder: (column) => column, + ); + + GeneratedColumn get icalUid => + $composableBuilder(column: $table.icalUid, builder: (column) => column); + + GeneratedColumn get recurrenceIdKey => $composableBuilder( + column: $table.recurrenceIdKey, + builder: (column) => column, + ); + + GeneratedColumn get occurrenceKey => $composableBuilder( + column: $table.occurrenceKey, + builder: (column) => column, + ); + + GeneratedColumn get projectionVersion => $composableBuilder( + column: $table.projectionVersion, + builder: (column) => column, + ); + + GeneratedColumn get providerRecurringEventId => $composableBuilder( + column: $table.providerRecurringEventId, + builder: (column) => column, + ); + + GeneratedColumn get providerOriginalStartKey => $composableBuilder( + column: $table.providerOriginalStartKey, + builder: (column) => column, + ); + + GeneratedColumn get etagOrChangeKey => $composableBuilder( + column: $table.etagOrChangeKey, + builder: (column) => column, + ); + + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); GeneratedColumn get description => $composableBuilder( column: $table.description, builder: (column) => column, ); - GeneratedColumn get primaryCalendar => $composableBuilder( - column: $table.primaryCalendar, + GeneratedColumn get location => + $composableBuilder(column: $table.location, builder: (column) => column); + + GeneratedColumn get allDay => + $composableBuilder(column: $table.allDay, builder: (column) => column); + + GeneratedColumn get startDate => + $composableBuilder(column: $table.startDate, builder: (column) => column); + + GeneratedColumn get startDateTime => $composableBuilder( + column: $table.startDateTime, builder: (column) => column, ); - GeneratedColumn get selected => - $composableBuilder(column: $table.selected, builder: (column) => column); + GeneratedColumn get startTimeZone => $composableBuilder( + column: $table.startTimeZone, + builder: (column) => column, + ); - GeneratedColumn get remindersEnabled => $composableBuilder( - column: $table.remindersEnabled, + GeneratedColumn get endDate => + $composableBuilder(column: $table.endDate, builder: (column) => column); + + GeneratedColumn get endDateTime => $composableBuilder( + column: $table.endDateTime, builder: (column) => column, ); - GeneratedColumn get hidden => - $composableBuilder(column: $table.hidden, builder: (column) => column); + GeneratedColumn get endTimeZone => $composableBuilder( + column: $table.endTimeZone, + builder: (column) => column, + ); - GeneratedColumn get readOnly => - $composableBuilder(column: $table.readOnly, builder: (column) => column); + GeneratedColumn get recurrenceJson => $composableBuilder( + column: $table.recurrenceJson, + builder: (column) => column, + ); - GeneratedColumn get backgroundColor => $composableBuilder( - column: $table.backgroundColor, + GeneratedColumn get remindersJson => $composableBuilder( + column: $table.remindersJson, builder: (column) => column, ); - GeneratedColumn get foregroundColor => $composableBuilder( - column: $table.foregroundColor, + GeneratedColumn get attendeesJson => $composableBuilder( + column: $table.attendeesJson, + builder: (column) => column, + ); + + GeneratedColumn get categoriesJson => $composableBuilder( + column: $table.categoriesJson, + builder: (column) => column, + ); + + GeneratedColumn get organizerJson => $composableBuilder( + column: $table.organizerJson, + builder: (column) => column, + ); + + GeneratedColumn get creatorJson => $composableBuilder( + column: $table.creatorJson, builder: (column) => column, ); GeneratedColumn get colorId => $composableBuilder(column: $table.colorId, builder: (column) => column); - GeneratedColumn get timeZone => - $composableBuilder(column: $table.timeZone, builder: (column) => column); + GeneratedColumn get colorHex => + $composableBuilder(column: $table.colorHex, builder: (column) => column); - GeneratedColumn get accessRole => $composableBuilder( - column: $table.accessRole, + GeneratedColumn get visibility => $composableBuilder( + column: $table.visibility, + builder: (column) => column, + ); + + GeneratedColumn get transparencyOrShowAs => $composableBuilder( + column: $table.transparencyOrShowAs, + builder: (column) => column, + ); + + GeneratedColumn get eventType => + $composableBuilder(column: $table.eventType, builder: (column) => column); + + GeneratedColumn get webLink => + $composableBuilder(column: $table.webLink, builder: (column) => column); + + GeneratedColumn get conferenceJson => $composableBuilder( + column: $table.conferenceJson, + builder: (column) => column, + ); + + GeneratedColumn get attachmentsJson => $composableBuilder( + column: $table.attachmentsJson, + builder: (column) => column, + ); + + GeneratedColumn get isCancelled => $composableBuilder( + column: $table.isCancelled, builder: (column) => column, ); @@ -34606,6 +38781,16 @@ class $$CalendarSourcesTableAnnotationComposer GeneratedColumn get rawJson => $composableBuilder(column: $table.rawJson, builder: (column) => column); + GeneratedColumn get createdAtServer => $composableBuilder( + column: $table.createdAtServer, + builder: (column) => column, + ); + + GeneratedColumn get updatedAtServer => $composableBuilder( + column: $table.updatedAtServer, + builder: (column) => column, + ); + GeneratedColumn get createdAtLocal => $composableBuilder( column: $table.createdAtLocal, builder: (column) => column, @@ -34616,6 +38801,16 @@ class $$CalendarSourcesTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get syncStatus => $composableBuilder( + column: $table.syncStatus, + builder: (column) => column, + ); + + GeneratedColumn get baselineRawJson => $composableBuilder( + column: $table.baselineRawJson, + builder: (column) => column, + ); + $$AccountsTableAnnotationComposer get accountId { final $$AccountsTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -34639,20 +38834,20 @@ class $$CalendarSourcesTableAnnotationComposer return composer; } - $$DavCollectionsTableAnnotationComposer get davCollectionId { - final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( + $$CalendarSourcesTableAnnotationComposer get calendarSourceId { + final $$CalendarSourcesTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableAnnotationComposer( + }) => $$CalendarSourcesTableAnnotationComposer( $db: $db, - $table: $db.davCollections, + $table: $db.calendarSources, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -34662,206 +38857,400 @@ class $$CalendarSourcesTableAnnotationComposer return composer; } - Expression calendarEventsRefs( - Expression Function($$CalendarEventsTableAnnotationComposer a) f, - ) { - final $$CalendarEventsTableAnnotationComposer composer = $composerBuilder( + $$DavCollectionsTableAnnotationComposer get davCollectionId { + final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarEvents, - getReferencedColumn: (t) => t.calendarSourceId, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventsTableAnnotationComposer( + }) => $$DavCollectionsTableAnnotationComposer( $db: $db, - $table: $db.calendarEvents, + $table: $db.davCollections, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, ), ); - return f(composer); + return composer; } - Expression syncCursorsRefs( - Expression Function($$SyncCursorsTableAnnotationComposer a) f, - ) { - final $$SyncCursorsTableAnnotationComposer composer = $composerBuilder( + $$DavObjectsTableAnnotationComposer get davObjectId { + final $$DavObjectsTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.syncCursors, - getReferencedColumn: (t) => t.projectionSourceId, + getCurrentColumn: (t) => t.davObjectId, + referencedTable: $db.davObjects, + getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$SyncCursorsTableAnnotationComposer( + }) => $$DavObjectsTableAnnotationComposer( $db: $db, - $table: $db.syncCursors, + $table: $db.davObjects, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, ), ); + return composer; + } + + $$DavObjectComponentsTableAnnotationComposer get davComponentId { + final $$DavObjectComponentsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davComponentId, + referencedTable: $db.davObjectComponents, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectComponentsTableAnnotationComposer( + $db: $db, + $table: $db.davObjectComponents, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + Expression calendarEventAttendeesRefs( + Expression Function($$CalendarEventAttendeesTableAnnotationComposer a) f, + ) { + final $$CalendarEventAttendeesTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarEventAttendees, + getReferencedColumn: (t) => t.calendarEventId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarEventAttendeesTableAnnotationComposer( + $db: $db, + $table: $db.calendarEventAttendees, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression calendarEventRemindersRefs( + Expression Function($$CalendarEventRemindersTableAnnotationComposer a) f, + ) { + final $$CalendarEventRemindersTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarEventReminders, + getReferencedColumn: (t) => t.calendarEventId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarEventRemindersTableAnnotationComposer( + $db: $db, + $table: $db.calendarEventReminders, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } } -class $$CalendarSourcesTableTableManager +class $$CalendarEventsTableTableManager extends RootTableManager< _$AppDatabase, - $CalendarSourcesTable, - CalendarSource, - $$CalendarSourcesTableFilterComposer, - $$CalendarSourcesTableOrderingComposer, - $$CalendarSourcesTableAnnotationComposer, - $$CalendarSourcesTableCreateCompanionBuilder, - $$CalendarSourcesTableUpdateCompanionBuilder, - (CalendarSource, $$CalendarSourcesTableReferences), - CalendarSource, + $CalendarEventsTable, + CalendarEvent, + $$CalendarEventsTableFilterComposer, + $$CalendarEventsTableOrderingComposer, + $$CalendarEventsTableAnnotationComposer, + $$CalendarEventsTableCreateCompanionBuilder, + $$CalendarEventsTableUpdateCompanionBuilder, + (CalendarEvent, $$CalendarEventsTableReferences), + CalendarEvent, PrefetchHooks Function({ bool accountId, + bool calendarSourceId, bool davCollectionId, - bool calendarEventsRefs, - bool syncCursorsRefs, + bool davObjectId, + bool davComponentId, + bool calendarEventAttendeesRefs, + bool calendarEventRemindersRefs, }) > { - $$CalendarSourcesTableTableManager( + $$CalendarEventsTableTableManager( _$AppDatabase db, - $CalendarSourcesTable table, + $CalendarEventsTable table, ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$CalendarSourcesTableFilterComposer($db: db, $table: table), + $$CalendarEventsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$CalendarSourcesTableOrderingComposer($db: db, $table: table), + $$CalendarEventsTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$CalendarSourcesTableAnnotationComposer($db: db, $table: table), + $$CalendarEventsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), Value accountId = const Value.absent(), + Value calendarSourceId = const Value.absent(), Value provider = const Value.absent(), Value providerCalendarId = const Value.absent(), + Value providerEventId = const Value.absent(), Value davCollectionId = const Value.absent(), - Value summary = const Value.absent(), + Value davObjectId = const Value.absent(), + Value davComponentId = const Value.absent(), + Value icalUid = const Value.absent(), + Value recurrenceIdKey = const Value.absent(), + Value occurrenceKey = const Value.absent(), + Value projectionVersion = const Value.absent(), + Value providerRecurringEventId = const Value.absent(), + Value providerOriginalStartKey = const Value.absent(), + Value etagOrChangeKey = const Value.absent(), + Value status = const Value.absent(), + Value title = const Value.absent(), Value description = const Value.absent(), - Value primaryCalendar = const Value.absent(), - Value selected = const Value.absent(), - Value remindersEnabled = const Value.absent(), - Value hidden = const Value.absent(), - Value readOnly = const Value.absent(), - Value backgroundColor = const Value.absent(), - Value foregroundColor = const Value.absent(), + Value location = const Value.absent(), + Value allDay = const Value.absent(), + Value startDate = const Value.absent(), + Value startDateTime = const Value.absent(), + Value startTimeZone = const Value.absent(), + Value endDate = const Value.absent(), + Value endDateTime = const Value.absent(), + Value endTimeZone = const Value.absent(), + Value recurrenceJson = const Value.absent(), + Value remindersJson = const Value.absent(), + Value attendeesJson = const Value.absent(), + Value categoriesJson = const Value.absent(), + Value organizerJson = const Value.absent(), + Value creatorJson = const Value.absent(), Value colorId = const Value.absent(), - Value timeZone = const Value.absent(), - Value accessRole = const Value.absent(), + Value colorHex = const Value.absent(), + Value visibility = const Value.absent(), + Value transparencyOrShowAs = const Value.absent(), + Value eventType = const Value.absent(), + Value webLink = const Value.absent(), + Value conferenceJson = const Value.absent(), + Value attachmentsJson = const Value.absent(), + Value isCancelled = const Value.absent(), Value isDeleted = const Value.absent(), Value rawJson = const Value.absent(), + Value createdAtServer = const Value.absent(), + Value updatedAtServer = const Value.absent(), Value createdAtLocal = const Value.absent(), Value updatedAtLocal = const Value.absent(), + Value syncStatus = const Value.absent(), + Value baselineRawJson = const Value.absent(), Value rowid = const Value.absent(), - }) => CalendarSourcesCompanion( + }) => CalendarEventsCompanion( id: id, accountId: accountId, + calendarSourceId: calendarSourceId, provider: provider, providerCalendarId: providerCalendarId, + providerEventId: providerEventId, davCollectionId: davCollectionId, - summary: summary, + davObjectId: davObjectId, + davComponentId: davComponentId, + icalUid: icalUid, + recurrenceIdKey: recurrenceIdKey, + occurrenceKey: occurrenceKey, + projectionVersion: projectionVersion, + providerRecurringEventId: providerRecurringEventId, + providerOriginalStartKey: providerOriginalStartKey, + etagOrChangeKey: etagOrChangeKey, + status: status, + title: title, description: description, - primaryCalendar: primaryCalendar, - selected: selected, - remindersEnabled: remindersEnabled, - hidden: hidden, - readOnly: readOnly, - backgroundColor: backgroundColor, - foregroundColor: foregroundColor, + location: location, + allDay: allDay, + startDate: startDate, + startDateTime: startDateTime, + startTimeZone: startTimeZone, + endDate: endDate, + endDateTime: endDateTime, + endTimeZone: endTimeZone, + recurrenceJson: recurrenceJson, + remindersJson: remindersJson, + attendeesJson: attendeesJson, + categoriesJson: categoriesJson, + organizerJson: organizerJson, + creatorJson: creatorJson, colorId: colorId, - timeZone: timeZone, - accessRole: accessRole, + colorHex: colorHex, + visibility: visibility, + transparencyOrShowAs: transparencyOrShowAs, + eventType: eventType, + webLink: webLink, + conferenceJson: conferenceJson, + attachmentsJson: attachmentsJson, + isCancelled: isCancelled, isDeleted: isDeleted, rawJson: rawJson, + createdAtServer: createdAtServer, + updatedAtServer: updatedAtServer, createdAtLocal: createdAtLocal, updatedAtLocal: updatedAtLocal, + syncStatus: syncStatus, + baselineRawJson: baselineRawJson, rowid: rowid, ), createCompanionCallback: ({ required String id, required String accountId, + required String calendarSourceId, required String provider, required String providerCalendarId, + required String providerEventId, Value davCollectionId = const Value.absent(), - required String summary, + Value davObjectId = const Value.absent(), + Value davComponentId = const Value.absent(), + Value icalUid = const Value.absent(), + Value recurrenceIdKey = const Value.absent(), + Value occurrenceKey = const Value.absent(), + Value projectionVersion = const Value.absent(), + Value providerRecurringEventId = const Value.absent(), + Value providerOriginalStartKey = const Value.absent(), + Value etagOrChangeKey = const Value.absent(), + Value status = const Value.absent(), + required String title, Value description = const Value.absent(), - Value primaryCalendar = const Value.absent(), - Value selected = const Value.absent(), - Value remindersEnabled = const Value.absent(), - Value hidden = const Value.absent(), - Value readOnly = const Value.absent(), - Value backgroundColor = const Value.absent(), - Value foregroundColor = const Value.absent(), + Value location = const Value.absent(), + Value allDay = const Value.absent(), + Value startDate = const Value.absent(), + Value startDateTime = const Value.absent(), + Value startTimeZone = const Value.absent(), + Value endDate = const Value.absent(), + Value endDateTime = const Value.absent(), + Value endTimeZone = const Value.absent(), + Value recurrenceJson = const Value.absent(), + Value remindersJson = const Value.absent(), + Value attendeesJson = const Value.absent(), + Value categoriesJson = const Value.absent(), + Value organizerJson = const Value.absent(), + Value creatorJson = const Value.absent(), Value colorId = const Value.absent(), - Value timeZone = const Value.absent(), - Value accessRole = const Value.absent(), + Value colorHex = const Value.absent(), + Value visibility = const Value.absent(), + Value transparencyOrShowAs = const Value.absent(), + Value eventType = const Value.absent(), + Value webLink = const Value.absent(), + Value conferenceJson = const Value.absent(), + Value attachmentsJson = const Value.absent(), + Value isCancelled = const Value.absent(), Value isDeleted = const Value.absent(), Value rawJson = const Value.absent(), + Value createdAtServer = const Value.absent(), + Value updatedAtServer = const Value.absent(), required int createdAtLocal, required int updatedAtLocal, + Value syncStatus = const Value.absent(), + Value baselineRawJson = const Value.absent(), Value rowid = const Value.absent(), - }) => CalendarSourcesCompanion.insert( + }) => CalendarEventsCompanion.insert( id: id, accountId: accountId, + calendarSourceId: calendarSourceId, provider: provider, providerCalendarId: providerCalendarId, + providerEventId: providerEventId, davCollectionId: davCollectionId, - summary: summary, + davObjectId: davObjectId, + davComponentId: davComponentId, + icalUid: icalUid, + recurrenceIdKey: recurrenceIdKey, + occurrenceKey: occurrenceKey, + projectionVersion: projectionVersion, + providerRecurringEventId: providerRecurringEventId, + providerOriginalStartKey: providerOriginalStartKey, + etagOrChangeKey: etagOrChangeKey, + status: status, + title: title, description: description, - primaryCalendar: primaryCalendar, - selected: selected, - remindersEnabled: remindersEnabled, - hidden: hidden, - readOnly: readOnly, - backgroundColor: backgroundColor, - foregroundColor: foregroundColor, + location: location, + allDay: allDay, + startDate: startDate, + startDateTime: startDateTime, + startTimeZone: startTimeZone, + endDate: endDate, + endDateTime: endDateTime, + endTimeZone: endTimeZone, + recurrenceJson: recurrenceJson, + remindersJson: remindersJson, + attendeesJson: attendeesJson, + categoriesJson: categoriesJson, + organizerJson: organizerJson, + creatorJson: creatorJson, colorId: colorId, - timeZone: timeZone, - accessRole: accessRole, + colorHex: colorHex, + visibility: visibility, + transparencyOrShowAs: transparencyOrShowAs, + eventType: eventType, + webLink: webLink, + conferenceJson: conferenceJson, + attachmentsJson: attachmentsJson, + isCancelled: isCancelled, isDeleted: isDeleted, rawJson: rawJson, + createdAtServer: createdAtServer, + updatedAtServer: updatedAtServer, createdAtLocal: createdAtLocal, updatedAtLocal: updatedAtLocal, + syncStatus: syncStatus, + baselineRawJson: baselineRawJson, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( e.readTable(table), - $$CalendarSourcesTableReferences(db, table, e), + $$CalendarEventsTableReferences(db, table, e), ), ) .toList(), prefetchHooksCallback: ({ accountId = false, + calendarSourceId = false, davCollectionId = false, - calendarEventsRefs = false, - syncCursorsRefs = false, + davObjectId = false, + davComponentId = false, + calendarEventAttendeesRefs = false, + calendarEventRemindersRefs = false, }) { return PrefetchHooks( db: db, explicitlyWatchedTables: [ - if (calendarEventsRefs) db.calendarEvents, - if (syncCursorsRefs) db.syncCursors, + if (calendarEventAttendeesRefs) db.calendarEventAttendees, + if (calendarEventRemindersRefs) db.calendarEventReminders, ], addJoins: < @@ -34885,216 +39274,546 @@ class $$CalendarSourcesTableTableManager currentTable: table, currentColumn: table.accountId, referencedTable: - $$CalendarSourcesTableReferences + $$CalendarEventsTableReferences ._accountIdTable(db), referencedColumn: - $$CalendarSourcesTableReferences + $$CalendarEventsTableReferences ._accountIdTable(db) .id, ) as T; } + if (calendarSourceId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.calendarSourceId, + referencedTable: + $$CalendarEventsTableReferences + ._calendarSourceIdTable(db), + referencedColumn: + $$CalendarEventsTableReferences + ._calendarSourceIdTable(db) + .id, + ) + as T; + } if (davCollectionId) { state = state.withJoin( currentTable: table, currentColumn: table.davCollectionId, referencedTable: - $$CalendarSourcesTableReferences + $$CalendarEventsTableReferences ._davCollectionIdTable(db), referencedColumn: - $$CalendarSourcesTableReferences + $$CalendarEventsTableReferences ._davCollectionIdTable(db) .id, ) as T; } + if (davObjectId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.davObjectId, + referencedTable: + $$CalendarEventsTableReferences + ._davObjectIdTable(db), + referencedColumn: + $$CalendarEventsTableReferences + ._davObjectIdTable(db) + .id, + ) + as T; + } + if (davComponentId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.davComponentId, + referencedTable: + $$CalendarEventsTableReferences + ._davComponentIdTable(db), + referencedColumn: + $$CalendarEventsTableReferences + ._davComponentIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return [ + if (calendarEventAttendeesRefs) + await $_getPrefetchedData< + CalendarEvent, + $CalendarEventsTable, + CalendarEventAttendee + >( + currentTable: table, + referencedTable: $$CalendarEventsTableReferences + ._calendarEventAttendeesRefsTable(db), + managerFromTypedResult: (p0) => + $$CalendarEventsTableReferences( + db, + table, + p0, + ).calendarEventAttendeesRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.calendarEventId == item.id, + ), + typedResults: items, + ), + if (calendarEventRemindersRefs) + await $_getPrefetchedData< + CalendarEvent, + $CalendarEventsTable, + CalendarEventReminder + >( + currentTable: table, + referencedTable: $$CalendarEventsTableReferences + ._calendarEventRemindersRefsTable(db), + managerFromTypedResult: (p0) => + $$CalendarEventsTableReferences( + db, + table, + p0, + ).calendarEventRemindersRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.calendarEventId == item.id, + ), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$CalendarEventsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $CalendarEventsTable, + CalendarEvent, + $$CalendarEventsTableFilterComposer, + $$CalendarEventsTableOrderingComposer, + $$CalendarEventsTableAnnotationComposer, + $$CalendarEventsTableCreateCompanionBuilder, + $$CalendarEventsTableUpdateCompanionBuilder, + (CalendarEvent, $$CalendarEventsTableReferences), + CalendarEvent, + PrefetchHooks Function({ + bool accountId, + bool calendarSourceId, + bool davCollectionId, + bool davObjectId, + bool davComponentId, + bool calendarEventAttendeesRefs, + bool calendarEventRemindersRefs, + }) + >; +typedef $$IcalImportReceiptsTableCreateCompanionBuilder = + IcalImportReceiptsCompanion Function({ + required String calendarSourceId, + required String icalUid, + Value eventId, + required String importedAtUtc, + Value rowid, + }); +typedef $$IcalImportReceiptsTableUpdateCompanionBuilder = + IcalImportReceiptsCompanion Function({ + Value calendarSourceId, + Value icalUid, + Value eventId, + Value importedAtUtc, + Value rowid, + }); + +final class $$IcalImportReceiptsTableReferences + extends + BaseReferences< + _$AppDatabase, + $IcalImportReceiptsTable, + IcalImportReceipt + > { + $$IcalImportReceiptsTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $CalendarSourcesTable _calendarSourceIdTable(_$AppDatabase db) => + db.calendarSources.createAlias( + $_aliasNameGenerator( + db.icalImportReceipts.calendarSourceId, + db.calendarSources.id, + ), + ); + + $$CalendarSourcesTableProcessedTableManager get calendarSourceId { + final $_column = $_itemColumn('calendar_source_id')!; + + final manager = $$CalendarSourcesTableTableManager( + $_db, + $_db.calendarSources, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_calendarSourceIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$IcalImportReceiptsTableFilterComposer + extends Composer<_$AppDatabase, $IcalImportReceiptsTable> { + $$IcalImportReceiptsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get icalUid => $composableBuilder( + column: $table.icalUid, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get eventId => $composableBuilder( + column: $table.eventId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get importedAtUtc => $composableBuilder( + column: $table.importedAtUtc, + builder: (column) => ColumnFilters(column), + ); + + $$CalendarSourcesTableFilterComposer get calendarSourceId { + final $$CalendarSourcesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarSourcesTableFilterComposer( + $db: $db, + $table: $db.calendarSources, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$IcalImportReceiptsTableOrderingComposer + extends Composer<_$AppDatabase, $IcalImportReceiptsTable> { + $$IcalImportReceiptsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get icalUid => $composableBuilder( + column: $table.icalUid, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get eventId => $composableBuilder( + column: $table.eventId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get importedAtUtc => $composableBuilder( + column: $table.importedAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + $$CalendarSourcesTableOrderingComposer get calendarSourceId { + final $$CalendarSourcesTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarSourcesTableOrderingComposer( + $db: $db, + $table: $db.calendarSources, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$IcalImportReceiptsTableAnnotationComposer + extends Composer<_$AppDatabase, $IcalImportReceiptsTable> { + $$IcalImportReceiptsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get icalUid => + $composableBuilder(column: $table.icalUid, builder: (column) => column); + + GeneratedColumn get eventId => + $composableBuilder(column: $table.eventId, builder: (column) => column); + + GeneratedColumn get importedAtUtc => $composableBuilder( + column: $table.importedAtUtc, + builder: (column) => column, + ); + + $$CalendarSourcesTableAnnotationComposer get calendarSourceId { + final $$CalendarSourcesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarSourcesTableAnnotationComposer( + $db: $db, + $table: $db.calendarSources, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$IcalImportReceiptsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $IcalImportReceiptsTable, + IcalImportReceipt, + $$IcalImportReceiptsTableFilterComposer, + $$IcalImportReceiptsTableOrderingComposer, + $$IcalImportReceiptsTableAnnotationComposer, + $$IcalImportReceiptsTableCreateCompanionBuilder, + $$IcalImportReceiptsTableUpdateCompanionBuilder, + (IcalImportReceipt, $$IcalImportReceiptsTableReferences), + IcalImportReceipt, + PrefetchHooks Function({bool calendarSourceId}) + > { + $$IcalImportReceiptsTableTableManager( + _$AppDatabase db, + $IcalImportReceiptsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$IcalImportReceiptsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$IcalImportReceiptsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$IcalImportReceiptsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value calendarSourceId = const Value.absent(), + Value icalUid = const Value.absent(), + Value eventId = const Value.absent(), + Value importedAtUtc = const Value.absent(), + Value rowid = const Value.absent(), + }) => IcalImportReceiptsCompanion( + calendarSourceId: calendarSourceId, + icalUid: icalUid, + eventId: eventId, + importedAtUtc: importedAtUtc, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String calendarSourceId, + required String icalUid, + Value eventId = const Value.absent(), + required String importedAtUtc, + Value rowid = const Value.absent(), + }) => IcalImportReceiptsCompanion.insert( + calendarSourceId: calendarSourceId, + icalUid: icalUid, + eventId: eventId, + importedAtUtc: importedAtUtc, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$IcalImportReceiptsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({calendarSourceId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (calendarSourceId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.calendarSourceId, + referencedTable: + $$IcalImportReceiptsTableReferences + ._calendarSourceIdTable(db), + referencedColumn: + $$IcalImportReceiptsTableReferences + ._calendarSourceIdTable(db) + .id, + ) + as T; + } - return state; - }, - getPrefetchedDataCallback: (items) async { - return [ - if (calendarEventsRefs) - await $_getPrefetchedData< - CalendarSource, - $CalendarSourcesTable, - CalendarEvent - >( - currentTable: table, - referencedTable: $$CalendarSourcesTableReferences - ._calendarEventsRefsTable(db), - managerFromTypedResult: (p0) => - $$CalendarSourcesTableReferences( - db, - table, - p0, - ).calendarEventsRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.calendarSourceId == item.id, - ), - typedResults: items, - ), - if (syncCursorsRefs) - await $_getPrefetchedData< - CalendarSource, - $CalendarSourcesTable, - SyncCursor - >( - currentTable: table, - referencedTable: $$CalendarSourcesTableReferences - ._syncCursorsRefsTable(db), - managerFromTypedResult: (p0) => - $$CalendarSourcesTableReferences( - db, - table, - p0, - ).syncCursorsRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.projectionSourceId == item.id, - ), - typedResults: items, - ), - ]; + return state; }, - ); + getPrefetchedDataCallback: (items) async { + return []; }, + ); + }, ), ); } -typedef $$CalendarSourcesTableProcessedTableManager = +typedef $$IcalImportReceiptsTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $CalendarSourcesTable, - CalendarSource, - $$CalendarSourcesTableFilterComposer, - $$CalendarSourcesTableOrderingComposer, - $$CalendarSourcesTableAnnotationComposer, - $$CalendarSourcesTableCreateCompanionBuilder, - $$CalendarSourcesTableUpdateCompanionBuilder, - (CalendarSource, $$CalendarSourcesTableReferences), - CalendarSource, - PrefetchHooks Function({ - bool accountId, - bool davCollectionId, - bool calendarEventsRefs, - bool syncCursorsRefs, - }) + $IcalImportReceiptsTable, + IcalImportReceipt, + $$IcalImportReceiptsTableFilterComposer, + $$IcalImportReceiptsTableOrderingComposer, + $$IcalImportReceiptsTableAnnotationComposer, + $$IcalImportReceiptsTableCreateCompanionBuilder, + $$IcalImportReceiptsTableUpdateCompanionBuilder, + (IcalImportReceipt, $$IcalImportReceiptsTableReferences), + IcalImportReceipt, + PrefetchHooks Function({bool calendarSourceId}) >; -typedef $$CalendarEventsTableCreateCompanionBuilder = - CalendarEventsCompanion Function({ +typedef $$WebCalSubscriptionsTableCreateCompanionBuilder = + WebCalSubscriptionsCompanion Function({ required String id, required String accountId, required String calendarSourceId, - required String provider, - required String providerCalendarId, - required String providerEventId, - Value davCollectionId, - Value davObjectId, - Value davComponentId, - Value icalUid, - Value recurrenceIdKey, - Value occurrenceKey, + required String feedFingerprint, + required String safeOrigin, + Value validatorTargetFingerprint, + Value etag, + Value lastModified, + Value contentType, + required String snapshotIcsBody, + required String rawBodyHash, + required String semanticHash, + required String refreshMode, + Value serverRefreshIntervalSeconds, + required String nextRefreshAtUtc, + Value lastCheckedAtUtc, + Value lastSuccessfulSyncAtUtc, + Value lastChangedAtUtc, + Value consecutiveFailureCount, + Value lastFailureCode, + Value lastFailureHttpStatus, + Value generation, + Value parserVersion, Value projectionVersion, - Value providerRecurringEventId, - Value providerOriginalStartKey, - Value etagOrChangeKey, - Value status, - required String title, - Value description, - Value location, - Value allDay, - Value startDate, - Value startDateTime, - Value startTimeZone, - Value endDate, - Value endDateTime, - Value endTimeZone, - Value recurrenceJson, - Value remindersJson, - Value attendeesJson, - Value categoriesJson, - Value organizerJson, - Value creatorJson, - Value colorId, - Value colorHex, - Value visibility, - Value transparencyOrShowAs, - Value eventType, - Value webLink, - Value conferenceJson, - Value attachmentsJson, - Value isCancelled, - Value isDeleted, - Value rawJson, - Value createdAtServer, - Value updatedAtServer, - required int createdAtLocal, - required int updatedAtLocal, - Value syncStatus, - Value baselineRawJson, + Value projectionRangeStartUtc, + Value projectionRangeEndUtc, + required String createdAtUtc, + required String updatedAtUtc, Value rowid, }); -typedef $$CalendarEventsTableUpdateCompanionBuilder = - CalendarEventsCompanion Function({ +typedef $$WebCalSubscriptionsTableUpdateCompanionBuilder = + WebCalSubscriptionsCompanion Function({ Value id, Value accountId, Value calendarSourceId, - Value provider, - Value providerCalendarId, - Value providerEventId, - Value davCollectionId, - Value davObjectId, - Value davComponentId, - Value icalUid, - Value recurrenceIdKey, - Value occurrenceKey, + Value feedFingerprint, + Value safeOrigin, + Value validatorTargetFingerprint, + Value etag, + Value lastModified, + Value contentType, + Value snapshotIcsBody, + Value rawBodyHash, + Value semanticHash, + Value refreshMode, + Value serverRefreshIntervalSeconds, + Value nextRefreshAtUtc, + Value lastCheckedAtUtc, + Value lastSuccessfulSyncAtUtc, + Value lastChangedAtUtc, + Value consecutiveFailureCount, + Value lastFailureCode, + Value lastFailureHttpStatus, + Value generation, + Value parserVersion, Value projectionVersion, - Value providerRecurringEventId, - Value providerOriginalStartKey, - Value etagOrChangeKey, - Value status, - Value title, - Value description, - Value location, - Value allDay, - Value startDate, - Value startDateTime, - Value startTimeZone, - Value endDate, - Value endDateTime, - Value endTimeZone, - Value recurrenceJson, - Value remindersJson, - Value attendeesJson, - Value categoriesJson, - Value organizerJson, - Value creatorJson, - Value colorId, - Value colorHex, - Value visibility, - Value transparencyOrShowAs, - Value eventType, - Value webLink, - Value conferenceJson, - Value attachmentsJson, - Value isCancelled, - Value isDeleted, - Value rawJson, - Value createdAtServer, - Value updatedAtServer, - Value createdAtLocal, - Value updatedAtLocal, - Value syncStatus, - Value baselineRawJson, + Value projectionRangeStartUtc, + Value projectionRangeEndUtc, + Value createdAtUtc, + Value updatedAtUtc, Value rowid, }); -final class $$CalendarEventsTableReferences - extends BaseReferences<_$AppDatabase, $CalendarEventsTable, CalendarEvent> { - $$CalendarEventsTableReferences( +final class $$WebCalSubscriptionsTableReferences + extends + BaseReferences< + _$AppDatabase, + $WebCalSubscriptionsTable, + WebCalSubscription + > { + $$WebCalSubscriptionsTableReferences( super.$_db, super.$_table, super.$_typedResult, @@ -35102,7 +39821,7 @@ final class $$CalendarEventsTableReferences static $AccountsTable _accountIdTable(_$AppDatabase db) => db.accounts.createAlias( - $_aliasNameGenerator(db.calendarEvents.accountId, db.accounts.id), + $_aliasNameGenerator(db.webCalSubscriptions.accountId, db.accounts.id), ); $$AccountsTableProcessedTableManager get accountId { @@ -35122,7 +39841,7 @@ final class $$CalendarEventsTableReferences static $CalendarSourcesTable _calendarSourceIdTable(_$AppDatabase db) => db.calendarSources.createAlias( $_aliasNameGenerator( - db.calendarEvents.calendarSourceId, + db.webCalSubscriptions.calendarSourceId, db.calendarSources.id, ), ); @@ -35140,136 +39859,11 @@ final class $$CalendarEventsTableReferences manager.$state.copyWith(prefetchedData: [item]), ); } - - static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => - db.davCollections.createAlias( - $_aliasNameGenerator( - db.calendarEvents.davCollectionId, - db.davCollections.id, - ), - ); - - $$DavCollectionsTableProcessedTableManager? get davCollectionId { - final $_column = $_itemColumn('dav_collection_id'); - if ($_column == null) return null; - final manager = $$DavCollectionsTableTableManager( - $_db, - $_db.davCollections, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_davCollectionIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } - - static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => - db.davObjects.createAlias( - $_aliasNameGenerator(db.calendarEvents.davObjectId, db.davObjects.id), - ); - - $$DavObjectsTableProcessedTableManager? get davObjectId { - final $_column = $_itemColumn('dav_object_id'); - if ($_column == null) return null; - final manager = $$DavObjectsTableTableManager( - $_db, - $_db.davObjects, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_davObjectIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } - - static $DavObjectComponentsTable _davComponentIdTable(_$AppDatabase db) => - db.davObjectComponents.createAlias( - $_aliasNameGenerator( - db.calendarEvents.davComponentId, - db.davObjectComponents.id, - ), - ); - - $$DavObjectComponentsTableProcessedTableManager? get davComponentId { - final $_column = $_itemColumn('dav_component_id'); - if ($_column == null) return null; - final manager = $$DavObjectComponentsTableTableManager( - $_db, - $_db.davObjectComponents, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_davComponentIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } - - static MultiTypedResultKey< - $CalendarEventAttendeesTable, - List - > - _calendarEventAttendeesRefsTable(_$AppDatabase db) => - MultiTypedResultKey.fromTable( - db.calendarEventAttendees, - aliasName: $_aliasNameGenerator( - db.calendarEvents.id, - db.calendarEventAttendees.calendarEventId, - ), - ); - - $$CalendarEventAttendeesTableProcessedTableManager - get calendarEventAttendeesRefs { - final manager = - $$CalendarEventAttendeesTableTableManager( - $_db, - $_db.calendarEventAttendees, - ).filter( - (f) => f.calendarEventId.id.sqlEquals($_itemColumn('id')!), - ); - - final cache = $_typedResult.readTableOrNull( - _calendarEventAttendeesRefsTable($_db), - ); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); - } - - static MultiTypedResultKey< - $CalendarEventRemindersTable, - List - > - _calendarEventRemindersRefsTable(_$AppDatabase db) => - MultiTypedResultKey.fromTable( - db.calendarEventReminders, - aliasName: $_aliasNameGenerator( - db.calendarEvents.id, - db.calendarEventReminders.calendarEventId, - ), - ); - - $$CalendarEventRemindersTableProcessedTableManager - get calendarEventRemindersRefs { - final manager = - $$CalendarEventRemindersTableTableManager( - $_db, - $_db.calendarEventReminders, - ).filter( - (f) => f.calendarEventId.id.sqlEquals($_itemColumn('id')!), - ); - - final cache = $_typedResult.readTableOrNull( - _calendarEventRemindersRefsTable($_db), - ); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); - } } -class $$CalendarEventsTableFilterComposer - extends Composer<_$AppDatabase, $CalendarEventsTable> { - $$CalendarEventsTableFilterComposer({ +class $$WebCalSubscriptionsTableFilterComposer + extends Composer<_$AppDatabase, $WebCalSubscriptionsTable> { + $$WebCalSubscriptionsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -35281,223 +39875,128 @@ class $$CalendarEventsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get provider => $composableBuilder( - column: $table.provider, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get providerCalendarId => $composableBuilder( - column: $table.providerCalendarId, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get providerEventId => $composableBuilder( - column: $table.providerEventId, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get icalUid => $composableBuilder( - column: $table.icalUid, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get recurrenceIdKey => $composableBuilder( - column: $table.recurrenceIdKey, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get occurrenceKey => $composableBuilder( - column: $table.occurrenceKey, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get projectionVersion => $composableBuilder( - column: $table.projectionVersion, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get providerRecurringEventId => $composableBuilder( - column: $table.providerRecurringEventId, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get providerOriginalStartKey => $composableBuilder( - column: $table.providerOriginalStartKey, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get etagOrChangeKey => $composableBuilder( - column: $table.etagOrChangeKey, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get status => $composableBuilder( - column: $table.status, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get title => $composableBuilder( - column: $table.title, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get description => $composableBuilder( - column: $table.description, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get location => $composableBuilder( - column: $table.location, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get allDay => $composableBuilder( - column: $table.allDay, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get startDate => $composableBuilder( - column: $table.startDate, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get startDateTime => $composableBuilder( - column: $table.startDateTime, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get startTimeZone => $composableBuilder( - column: $table.startTimeZone, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get endDate => $composableBuilder( - column: $table.endDate, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get endDateTime => $composableBuilder( - column: $table.endDateTime, + ColumnFilters get feedFingerprint => $composableBuilder( + column: $table.feedFingerprint, builder: (column) => ColumnFilters(column), ); - ColumnFilters get endTimeZone => $composableBuilder( - column: $table.endTimeZone, + ColumnFilters get safeOrigin => $composableBuilder( + column: $table.safeOrigin, builder: (column) => ColumnFilters(column), ); - ColumnFilters get recurrenceJson => $composableBuilder( - column: $table.recurrenceJson, + ColumnFilters get validatorTargetFingerprint => $composableBuilder( + column: $table.validatorTargetFingerprint, builder: (column) => ColumnFilters(column), ); - ColumnFilters get remindersJson => $composableBuilder( - column: $table.remindersJson, + ColumnFilters get etag => $composableBuilder( + column: $table.etag, builder: (column) => ColumnFilters(column), ); - ColumnFilters get attendeesJson => $composableBuilder( - column: $table.attendeesJson, + ColumnFilters get lastModified => $composableBuilder( + column: $table.lastModified, builder: (column) => ColumnFilters(column), ); - ColumnFilters get categoriesJson => $composableBuilder( - column: $table.categoriesJson, + ColumnFilters get contentType => $composableBuilder( + column: $table.contentType, builder: (column) => ColumnFilters(column), ); - ColumnFilters get organizerJson => $composableBuilder( - column: $table.organizerJson, + ColumnFilters get snapshotIcsBody => $composableBuilder( + column: $table.snapshotIcsBody, builder: (column) => ColumnFilters(column), ); - ColumnFilters get creatorJson => $composableBuilder( - column: $table.creatorJson, + ColumnFilters get rawBodyHash => $composableBuilder( + column: $table.rawBodyHash, builder: (column) => ColumnFilters(column), ); - ColumnFilters get colorId => $composableBuilder( - column: $table.colorId, + ColumnFilters get semanticHash => $composableBuilder( + column: $table.semanticHash, builder: (column) => ColumnFilters(column), ); - ColumnFilters get colorHex => $composableBuilder( - column: $table.colorHex, + ColumnFilters get refreshMode => $composableBuilder( + column: $table.refreshMode, builder: (column) => ColumnFilters(column), ); - ColumnFilters get visibility => $composableBuilder( - column: $table.visibility, + ColumnFilters get serverRefreshIntervalSeconds => $composableBuilder( + column: $table.serverRefreshIntervalSeconds, builder: (column) => ColumnFilters(column), ); - ColumnFilters get transparencyOrShowAs => $composableBuilder( - column: $table.transparencyOrShowAs, + ColumnFilters get nextRefreshAtUtc => $composableBuilder( + column: $table.nextRefreshAtUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get eventType => $composableBuilder( - column: $table.eventType, + ColumnFilters get lastCheckedAtUtc => $composableBuilder( + column: $table.lastCheckedAtUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get webLink => $composableBuilder( - column: $table.webLink, + ColumnFilters get lastSuccessfulSyncAtUtc => $composableBuilder( + column: $table.lastSuccessfulSyncAtUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get conferenceJson => $composableBuilder( - column: $table.conferenceJson, + ColumnFilters get lastChangedAtUtc => $composableBuilder( + column: $table.lastChangedAtUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get attachmentsJson => $composableBuilder( - column: $table.attachmentsJson, + ColumnFilters get consecutiveFailureCount => $composableBuilder( + column: $table.consecutiveFailureCount, builder: (column) => ColumnFilters(column), ); - ColumnFilters get isCancelled => $composableBuilder( - column: $table.isCancelled, + ColumnFilters get lastFailureCode => $composableBuilder( + column: $table.lastFailureCode, builder: (column) => ColumnFilters(column), ); - ColumnFilters get isDeleted => $composableBuilder( - column: $table.isDeleted, + ColumnFilters get lastFailureHttpStatus => $composableBuilder( + column: $table.lastFailureHttpStatus, builder: (column) => ColumnFilters(column), ); - ColumnFilters get rawJson => $composableBuilder( - column: $table.rawJson, + ColumnFilters get generation => $composableBuilder( + column: $table.generation, builder: (column) => ColumnFilters(column), ); - ColumnFilters get createdAtServer => $composableBuilder( - column: $table.createdAtServer, + ColumnFilters get parserVersion => $composableBuilder( + column: $table.parserVersion, builder: (column) => ColumnFilters(column), ); - ColumnFilters get updatedAtServer => $composableBuilder( - column: $table.updatedAtServer, + ColumnFilters get projectionVersion => $composableBuilder( + column: $table.projectionVersion, builder: (column) => ColumnFilters(column), ); - ColumnFilters get createdAtLocal => $composableBuilder( - column: $table.createdAtLocal, + ColumnFilters get projectionRangeStartUtc => $composableBuilder( + column: $table.projectionRangeStartUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get updatedAtLocal => $composableBuilder( - column: $table.updatedAtLocal, + ColumnFilters get projectionRangeEndUtc => $composableBuilder( + column: $table.projectionRangeEndUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get syncStatus => $composableBuilder( - column: $table.syncStatus, + ColumnFilters get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get baselineRawJson => $composableBuilder( - column: $table.baselineRawJson, + ColumnFilters get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, builder: (column) => ColumnFilters(column), ); @@ -35546,132 +40045,11 @@ class $$CalendarEventsTableFilterComposer ); return composer; } - - $$DavCollectionsTableFilterComposer get davCollectionId { - final $$DavCollectionsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableFilterComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavObjectsTableFilterComposer get davObjectId { - final $$DavObjectsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davObjectId, - referencedTable: $db.davObjects, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableFilterComposer( - $db: $db, - $table: $db.davObjects, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavObjectComponentsTableFilterComposer get davComponentId { - final $$DavObjectComponentsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davComponentId, - referencedTable: $db.davObjectComponents, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavObjectComponentsTableFilterComposer( - $db: $db, - $table: $db.davObjectComponents, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - Expression calendarEventAttendeesRefs( - Expression Function($$CalendarEventAttendeesTableFilterComposer f) f, - ) { - final $$CalendarEventAttendeesTableFilterComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarEventAttendees, - getReferencedColumn: (t) => t.calendarEventId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventAttendeesTableFilterComposer( - $db: $db, - $table: $db.calendarEventAttendees, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } - - Expression calendarEventRemindersRefs( - Expression Function($$CalendarEventRemindersTableFilterComposer f) f, - ) { - final $$CalendarEventRemindersTableFilterComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarEventReminders, - getReferencedColumn: (t) => t.calendarEventId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventRemindersTableFilterComposer( - $db: $db, - $table: $db.calendarEventReminders, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } } -class $$CalendarEventsTableOrderingComposer - extends Composer<_$AppDatabase, $CalendarEventsTable> { - $$CalendarEventsTableOrderingComposer({ +class $$WebCalSubscriptionsTableOrderingComposer + extends Composer<_$AppDatabase, $WebCalSubscriptionsTable> { + $$WebCalSubscriptionsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -35683,223 +40061,128 @@ class $$CalendarEventsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get provider => $composableBuilder( - column: $table.provider, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get providerCalendarId => $composableBuilder( - column: $table.providerCalendarId, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get providerEventId => $composableBuilder( - column: $table.providerEventId, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get icalUid => $composableBuilder( - column: $table.icalUid, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get recurrenceIdKey => $composableBuilder( - column: $table.recurrenceIdKey, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get occurrenceKey => $composableBuilder( - column: $table.occurrenceKey, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get projectionVersion => $composableBuilder( - column: $table.projectionVersion, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get providerRecurringEventId => $composableBuilder( - column: $table.providerRecurringEventId, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get providerOriginalStartKey => $composableBuilder( - column: $table.providerOriginalStartKey, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get etagOrChangeKey => $composableBuilder( - column: $table.etagOrChangeKey, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get status => $composableBuilder( - column: $table.status, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get title => $composableBuilder( - column: $table.title, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get description => $composableBuilder( - column: $table.description, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get location => $composableBuilder( - column: $table.location, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get allDay => $composableBuilder( - column: $table.allDay, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get startDate => $composableBuilder( - column: $table.startDate, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get startDateTime => $composableBuilder( - column: $table.startDateTime, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get startTimeZone => $composableBuilder( - column: $table.startTimeZone, + ColumnOrderings get feedFingerprint => $composableBuilder( + column: $table.feedFingerprint, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get endDate => $composableBuilder( - column: $table.endDate, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get endDateTime => $composableBuilder( - column: $table.endDateTime, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get endTimeZone => $composableBuilder( - column: $table.endTimeZone, + ColumnOrderings get safeOrigin => $composableBuilder( + column: $table.safeOrigin, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get recurrenceJson => $composableBuilder( - column: $table.recurrenceJson, + ColumnOrderings get validatorTargetFingerprint => $composableBuilder( + column: $table.validatorTargetFingerprint, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get remindersJson => $composableBuilder( - column: $table.remindersJson, + ColumnOrderings get etag => $composableBuilder( + column: $table.etag, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get attendeesJson => $composableBuilder( - column: $table.attendeesJson, + ColumnOrderings get lastModified => $composableBuilder( + column: $table.lastModified, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get categoriesJson => $composableBuilder( - column: $table.categoriesJson, + ColumnOrderings get contentType => $composableBuilder( + column: $table.contentType, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get organizerJson => $composableBuilder( - column: $table.organizerJson, + ColumnOrderings get snapshotIcsBody => $composableBuilder( + column: $table.snapshotIcsBody, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get creatorJson => $composableBuilder( - column: $table.creatorJson, + ColumnOrderings get rawBodyHash => $composableBuilder( + column: $table.rawBodyHash, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get colorId => $composableBuilder( - column: $table.colorId, + ColumnOrderings get semanticHash => $composableBuilder( + column: $table.semanticHash, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get colorHex => $composableBuilder( - column: $table.colorHex, + ColumnOrderings get refreshMode => $composableBuilder( + column: $table.refreshMode, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get visibility => $composableBuilder( - column: $table.visibility, + ColumnOrderings get serverRefreshIntervalSeconds => $composableBuilder( + column: $table.serverRefreshIntervalSeconds, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get transparencyOrShowAs => $composableBuilder( - column: $table.transparencyOrShowAs, + ColumnOrderings get nextRefreshAtUtc => $composableBuilder( + column: $table.nextRefreshAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get eventType => $composableBuilder( - column: $table.eventType, + ColumnOrderings get lastCheckedAtUtc => $composableBuilder( + column: $table.lastCheckedAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get webLink => $composableBuilder( - column: $table.webLink, + ColumnOrderings get lastSuccessfulSyncAtUtc => $composableBuilder( + column: $table.lastSuccessfulSyncAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get conferenceJson => $composableBuilder( - column: $table.conferenceJson, + ColumnOrderings get lastChangedAtUtc => $composableBuilder( + column: $table.lastChangedAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get attachmentsJson => $composableBuilder( - column: $table.attachmentsJson, + ColumnOrderings get consecutiveFailureCount => $composableBuilder( + column: $table.consecutiveFailureCount, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get isCancelled => $composableBuilder( - column: $table.isCancelled, + ColumnOrderings get lastFailureCode => $composableBuilder( + column: $table.lastFailureCode, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get isDeleted => $composableBuilder( - column: $table.isDeleted, + ColumnOrderings get lastFailureHttpStatus => $composableBuilder( + column: $table.lastFailureHttpStatus, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get rawJson => $composableBuilder( - column: $table.rawJson, + ColumnOrderings get generation => $composableBuilder( + column: $table.generation, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get createdAtServer => $composableBuilder( - column: $table.createdAtServer, + ColumnOrderings get parserVersion => $composableBuilder( + column: $table.parserVersion, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get updatedAtServer => $composableBuilder( - column: $table.updatedAtServer, + ColumnOrderings get projectionVersion => $composableBuilder( + column: $table.projectionVersion, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get createdAtLocal => $composableBuilder( - column: $table.createdAtLocal, + ColumnOrderings get projectionRangeStartUtc => $composableBuilder( + column: $table.projectionRangeStartUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get updatedAtLocal => $composableBuilder( - column: $table.updatedAtLocal, + ColumnOrderings get projectionRangeEndUtc => $composableBuilder( + column: $table.projectionRangeEndUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get syncStatus => $composableBuilder( - column: $table.syncStatus, + ColumnOrderings get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get baselineRawJson => $composableBuilder( - column: $table.baselineRawJson, + ColumnOrderings get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, builder: (column) => ColumnOrderings(column), ); @@ -35948,81 +40231,11 @@ class $$CalendarEventsTableOrderingComposer ); return composer; } - - $$DavCollectionsTableOrderingComposer get davCollectionId { - final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableOrderingComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavObjectsTableOrderingComposer get davObjectId { - final $$DavObjectsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davObjectId, - referencedTable: $db.davObjects, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableOrderingComposer( - $db: $db, - $table: $db.davObjects, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavObjectComponentsTableOrderingComposer get davComponentId { - final $$DavObjectComponentsTableOrderingComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davComponentId, - referencedTable: $db.davObjectComponents, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavObjectComponentsTableOrderingComposer( - $db: $db, - $table: $db.davObjectComponents, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } } -class $$CalendarEventsTableAnnotationComposer - extends Composer<_$AppDatabase, $CalendarEventsTable> { - $$CalendarEventsTableAnnotationComposer({ +class $$WebCalSubscriptionsTableAnnotationComposer + extends Composer<_$AppDatabase, $WebCalSubscriptionsTable> { + $$WebCalSubscriptionsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -36032,195 +40245,126 @@ class $$CalendarEventsTableAnnotationComposer GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get provider => - $composableBuilder(column: $table.provider, builder: (column) => column); - - GeneratedColumn get providerCalendarId => $composableBuilder( - column: $table.providerCalendarId, - builder: (column) => column, - ); - - GeneratedColumn get providerEventId => $composableBuilder( - column: $table.providerEventId, - builder: (column) => column, - ); - - GeneratedColumn get icalUid => - $composableBuilder(column: $table.icalUid, builder: (column) => column); - - GeneratedColumn get recurrenceIdKey => $composableBuilder( - column: $table.recurrenceIdKey, - builder: (column) => column, - ); - - GeneratedColumn get occurrenceKey => $composableBuilder( - column: $table.occurrenceKey, - builder: (column) => column, - ); - - GeneratedColumn get projectionVersion => $composableBuilder( - column: $table.projectionVersion, - builder: (column) => column, - ); - - GeneratedColumn get providerRecurringEventId => $composableBuilder( - column: $table.providerRecurringEventId, - builder: (column) => column, - ); - - GeneratedColumn get providerOriginalStartKey => $composableBuilder( - column: $table.providerOriginalStartKey, + GeneratedColumn get feedFingerprint => $composableBuilder( + column: $table.feedFingerprint, builder: (column) => column, ); - GeneratedColumn get etagOrChangeKey => $composableBuilder( - column: $table.etagOrChangeKey, + GeneratedColumn get safeOrigin => $composableBuilder( + column: $table.safeOrigin, builder: (column) => column, ); - GeneratedColumn get status => - $composableBuilder(column: $table.status, builder: (column) => column); - - GeneratedColumn get title => - $composableBuilder(column: $table.title, builder: (column) => column); - - GeneratedColumn get description => $composableBuilder( - column: $table.description, + GeneratedColumn get validatorTargetFingerprint => $composableBuilder( + column: $table.validatorTargetFingerprint, builder: (column) => column, ); - GeneratedColumn get location => - $composableBuilder(column: $table.location, builder: (column) => column); - - GeneratedColumn get allDay => - $composableBuilder(column: $table.allDay, builder: (column) => column); - - GeneratedColumn get startDate => - $composableBuilder(column: $table.startDate, builder: (column) => column); + GeneratedColumn get etag => + $composableBuilder(column: $table.etag, builder: (column) => column); - GeneratedColumn get startDateTime => $composableBuilder( - column: $table.startDateTime, + GeneratedColumn get lastModified => $composableBuilder( + column: $table.lastModified, builder: (column) => column, ); - GeneratedColumn get startTimeZone => $composableBuilder( - column: $table.startTimeZone, + GeneratedColumn get contentType => $composableBuilder( + column: $table.contentType, builder: (column) => column, ); - GeneratedColumn get endDate => - $composableBuilder(column: $table.endDate, builder: (column) => column); - - GeneratedColumn get endDateTime => $composableBuilder( - column: $table.endDateTime, + GeneratedColumn get snapshotIcsBody => $composableBuilder( + column: $table.snapshotIcsBody, builder: (column) => column, ); - GeneratedColumn get endTimeZone => $composableBuilder( - column: $table.endTimeZone, + GeneratedColumn get rawBodyHash => $composableBuilder( + column: $table.rawBodyHash, builder: (column) => column, ); - GeneratedColumn get recurrenceJson => $composableBuilder( - column: $table.recurrenceJson, + GeneratedColumn get semanticHash => $composableBuilder( + column: $table.semanticHash, builder: (column) => column, ); - GeneratedColumn get remindersJson => $composableBuilder( - column: $table.remindersJson, + GeneratedColumn get refreshMode => $composableBuilder( + column: $table.refreshMode, builder: (column) => column, ); - GeneratedColumn get attendeesJson => $composableBuilder( - column: $table.attendeesJson, + GeneratedColumn get serverRefreshIntervalSeconds => $composableBuilder( + column: $table.serverRefreshIntervalSeconds, builder: (column) => column, ); - GeneratedColumn get categoriesJson => $composableBuilder( - column: $table.categoriesJson, + GeneratedColumn get nextRefreshAtUtc => $composableBuilder( + column: $table.nextRefreshAtUtc, builder: (column) => column, ); - GeneratedColumn get organizerJson => $composableBuilder( - column: $table.organizerJson, + GeneratedColumn get lastCheckedAtUtc => $composableBuilder( + column: $table.lastCheckedAtUtc, builder: (column) => column, ); - GeneratedColumn get creatorJson => $composableBuilder( - column: $table.creatorJson, + GeneratedColumn get lastSuccessfulSyncAtUtc => $composableBuilder( + column: $table.lastSuccessfulSyncAtUtc, builder: (column) => column, ); - GeneratedColumn get colorId => - $composableBuilder(column: $table.colorId, builder: (column) => column); - - GeneratedColumn get colorHex => - $composableBuilder(column: $table.colorHex, builder: (column) => column); - - GeneratedColumn get visibility => $composableBuilder( - column: $table.visibility, + GeneratedColumn get lastChangedAtUtc => $composableBuilder( + column: $table.lastChangedAtUtc, builder: (column) => column, ); - GeneratedColumn get transparencyOrShowAs => $composableBuilder( - column: $table.transparencyOrShowAs, + GeneratedColumn get consecutiveFailureCount => $composableBuilder( + column: $table.consecutiveFailureCount, builder: (column) => column, ); - GeneratedColumn get eventType => - $composableBuilder(column: $table.eventType, builder: (column) => column); - - GeneratedColumn get webLink => - $composableBuilder(column: $table.webLink, builder: (column) => column); - - GeneratedColumn get conferenceJson => $composableBuilder( - column: $table.conferenceJson, + GeneratedColumn get lastFailureCode => $composableBuilder( + column: $table.lastFailureCode, builder: (column) => column, ); - GeneratedColumn get attachmentsJson => $composableBuilder( - column: $table.attachmentsJson, + GeneratedColumn get lastFailureHttpStatus => $composableBuilder( + column: $table.lastFailureHttpStatus, builder: (column) => column, ); - GeneratedColumn get isCancelled => $composableBuilder( - column: $table.isCancelled, + GeneratedColumn get generation => $composableBuilder( + column: $table.generation, builder: (column) => column, ); - GeneratedColumn get isDeleted => - $composableBuilder(column: $table.isDeleted, builder: (column) => column); - - GeneratedColumn get rawJson => - $composableBuilder(column: $table.rawJson, builder: (column) => column); - - GeneratedColumn get createdAtServer => $composableBuilder( - column: $table.createdAtServer, + GeneratedColumn get parserVersion => $composableBuilder( + column: $table.parserVersion, builder: (column) => column, ); - GeneratedColumn get updatedAtServer => $composableBuilder( - column: $table.updatedAtServer, + GeneratedColumn get projectionVersion => $composableBuilder( + column: $table.projectionVersion, builder: (column) => column, ); - GeneratedColumn get createdAtLocal => $composableBuilder( - column: $table.createdAtLocal, + GeneratedColumn get projectionRangeStartUtc => $composableBuilder( + column: $table.projectionRangeStartUtc, builder: (column) => column, ); - GeneratedColumn get updatedAtLocal => $composableBuilder( - column: $table.updatedAtLocal, + GeneratedColumn get projectionRangeEndUtc => $composableBuilder( + column: $table.projectionRangeEndUtc, builder: (column) => column, ); - GeneratedColumn get syncStatus => $composableBuilder( - column: $table.syncStatus, + GeneratedColumn get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, builder: (column) => column, ); - GeneratedColumn get baselineRawJson => $composableBuilder( - column: $table.baselineRawJson, + GeneratedColumn get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, builder: (column) => column, ); @@ -36269,270 +40413,103 @@ class $$CalendarEventsTableAnnotationComposer ); return composer; } - - $$DavCollectionsTableAnnotationComposer get davCollectionId { - final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableAnnotationComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavObjectsTableAnnotationComposer get davObjectId { - final $$DavObjectsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davObjectId, - referencedTable: $db.davObjects, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableAnnotationComposer( - $db: $db, - $table: $db.davObjects, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavObjectComponentsTableAnnotationComposer get davComponentId { - final $$DavObjectComponentsTableAnnotationComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davComponentId, - referencedTable: $db.davObjectComponents, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavObjectComponentsTableAnnotationComposer( - $db: $db, - $table: $db.davObjectComponents, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - Expression calendarEventAttendeesRefs( - Expression Function($$CalendarEventAttendeesTableAnnotationComposer a) f, - ) { - final $$CalendarEventAttendeesTableAnnotationComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarEventAttendees, - getReferencedColumn: (t) => t.calendarEventId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventAttendeesTableAnnotationComposer( - $db: $db, - $table: $db.calendarEventAttendees, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } - - Expression calendarEventRemindersRefs( - Expression Function($$CalendarEventRemindersTableAnnotationComposer a) f, - ) { - final $$CalendarEventRemindersTableAnnotationComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarEventReminders, - getReferencedColumn: (t) => t.calendarEventId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventRemindersTableAnnotationComposer( - $db: $db, - $table: $db.calendarEventReminders, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } } -class $$CalendarEventsTableTableManager +class $$WebCalSubscriptionsTableTableManager extends RootTableManager< _$AppDatabase, - $CalendarEventsTable, - CalendarEvent, - $$CalendarEventsTableFilterComposer, - $$CalendarEventsTableOrderingComposer, - $$CalendarEventsTableAnnotationComposer, - $$CalendarEventsTableCreateCompanionBuilder, - $$CalendarEventsTableUpdateCompanionBuilder, - (CalendarEvent, $$CalendarEventsTableReferences), - CalendarEvent, - PrefetchHooks Function({ - bool accountId, - bool calendarSourceId, - bool davCollectionId, - bool davObjectId, - bool davComponentId, - bool calendarEventAttendeesRefs, - bool calendarEventRemindersRefs, - }) + $WebCalSubscriptionsTable, + WebCalSubscription, + $$WebCalSubscriptionsTableFilterComposer, + $$WebCalSubscriptionsTableOrderingComposer, + $$WebCalSubscriptionsTableAnnotationComposer, + $$WebCalSubscriptionsTableCreateCompanionBuilder, + $$WebCalSubscriptionsTableUpdateCompanionBuilder, + (WebCalSubscription, $$WebCalSubscriptionsTableReferences), + WebCalSubscription, + PrefetchHooks Function({bool accountId, bool calendarSourceId}) > { - $$CalendarEventsTableTableManager( + $$WebCalSubscriptionsTableTableManager( _$AppDatabase db, - $CalendarEventsTable table, + $WebCalSubscriptionsTable table, ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$CalendarEventsTableFilterComposer($db: db, $table: table), + $$WebCalSubscriptionsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$CalendarEventsTableOrderingComposer($db: db, $table: table), + $$WebCalSubscriptionsTableOrderingComposer( + $db: db, + $table: table, + ), createComputedFieldComposer: () => - $$CalendarEventsTableAnnotationComposer($db: db, $table: table), + $$WebCalSubscriptionsTableAnnotationComposer( + $db: db, + $table: table, + ), updateCompanionCallback: ({ Value id = const Value.absent(), Value accountId = const Value.absent(), Value calendarSourceId = const Value.absent(), - Value provider = const Value.absent(), - Value providerCalendarId = const Value.absent(), - Value providerEventId = const Value.absent(), - Value davCollectionId = const Value.absent(), - Value davObjectId = const Value.absent(), - Value davComponentId = const Value.absent(), - Value icalUid = const Value.absent(), - Value recurrenceIdKey = const Value.absent(), - Value occurrenceKey = const Value.absent(), + Value feedFingerprint = const Value.absent(), + Value safeOrigin = const Value.absent(), + Value validatorTargetFingerprint = + const Value.absent(), + Value etag = const Value.absent(), + Value lastModified = const Value.absent(), + Value contentType = const Value.absent(), + Value snapshotIcsBody = const Value.absent(), + Value rawBodyHash = const Value.absent(), + Value semanticHash = const Value.absent(), + Value refreshMode = const Value.absent(), + Value serverRefreshIntervalSeconds = const Value.absent(), + Value nextRefreshAtUtc = const Value.absent(), + Value lastCheckedAtUtc = const Value.absent(), + Value lastSuccessfulSyncAtUtc = const Value.absent(), + Value lastChangedAtUtc = const Value.absent(), + Value consecutiveFailureCount = const Value.absent(), + Value lastFailureCode = const Value.absent(), + Value lastFailureHttpStatus = const Value.absent(), + Value generation = const Value.absent(), + Value parserVersion = const Value.absent(), Value projectionVersion = const Value.absent(), - Value providerRecurringEventId = const Value.absent(), - Value providerOriginalStartKey = const Value.absent(), - Value etagOrChangeKey = const Value.absent(), - Value status = const Value.absent(), - Value title = const Value.absent(), - Value description = const Value.absent(), - Value location = const Value.absent(), - Value allDay = const Value.absent(), - Value startDate = const Value.absent(), - Value startDateTime = const Value.absent(), - Value startTimeZone = const Value.absent(), - Value endDate = const Value.absent(), - Value endDateTime = const Value.absent(), - Value endTimeZone = const Value.absent(), - Value recurrenceJson = const Value.absent(), - Value remindersJson = const Value.absent(), - Value attendeesJson = const Value.absent(), - Value categoriesJson = const Value.absent(), - Value organizerJson = const Value.absent(), - Value creatorJson = const Value.absent(), - Value colorId = const Value.absent(), - Value colorHex = const Value.absent(), - Value visibility = const Value.absent(), - Value transparencyOrShowAs = const Value.absent(), - Value eventType = const Value.absent(), - Value webLink = const Value.absent(), - Value conferenceJson = const Value.absent(), - Value attachmentsJson = const Value.absent(), - Value isCancelled = const Value.absent(), - Value isDeleted = const Value.absent(), - Value rawJson = const Value.absent(), - Value createdAtServer = const Value.absent(), - Value updatedAtServer = const Value.absent(), - Value createdAtLocal = const Value.absent(), - Value updatedAtLocal = const Value.absent(), - Value syncStatus = const Value.absent(), - Value baselineRawJson = const Value.absent(), + Value projectionRangeStartUtc = const Value.absent(), + Value projectionRangeEndUtc = const Value.absent(), + Value createdAtUtc = const Value.absent(), + Value updatedAtUtc = const Value.absent(), Value rowid = const Value.absent(), - }) => CalendarEventsCompanion( + }) => WebCalSubscriptionsCompanion( id: id, accountId: accountId, calendarSourceId: calendarSourceId, - provider: provider, - providerCalendarId: providerCalendarId, - providerEventId: providerEventId, - davCollectionId: davCollectionId, - davObjectId: davObjectId, - davComponentId: davComponentId, - icalUid: icalUid, - recurrenceIdKey: recurrenceIdKey, - occurrenceKey: occurrenceKey, + feedFingerprint: feedFingerprint, + safeOrigin: safeOrigin, + validatorTargetFingerprint: validatorTargetFingerprint, + etag: etag, + lastModified: lastModified, + contentType: contentType, + snapshotIcsBody: snapshotIcsBody, + rawBodyHash: rawBodyHash, + semanticHash: semanticHash, + refreshMode: refreshMode, + serverRefreshIntervalSeconds: serverRefreshIntervalSeconds, + nextRefreshAtUtc: nextRefreshAtUtc, + lastCheckedAtUtc: lastCheckedAtUtc, + lastSuccessfulSyncAtUtc: lastSuccessfulSyncAtUtc, + lastChangedAtUtc: lastChangedAtUtc, + consecutiveFailureCount: consecutiveFailureCount, + lastFailureCode: lastFailureCode, + lastFailureHttpStatus: lastFailureHttpStatus, + generation: generation, + parserVersion: parserVersion, projectionVersion: projectionVersion, - providerRecurringEventId: providerRecurringEventId, - providerOriginalStartKey: providerOriginalStartKey, - etagOrChangeKey: etagOrChangeKey, - status: status, - title: title, - description: description, - location: location, - allDay: allDay, - startDate: startDate, - startDateTime: startDateTime, - startTimeZone: startTimeZone, - endDate: endDate, - endDateTime: endDateTime, - endTimeZone: endTimeZone, - recurrenceJson: recurrenceJson, - remindersJson: remindersJson, - attendeesJson: attendeesJson, - categoriesJson: categoriesJson, - organizerJson: organizerJson, - creatorJson: creatorJson, - colorId: colorId, - colorHex: colorHex, - visibility: visibility, - transparencyOrShowAs: transparencyOrShowAs, - eventType: eventType, - webLink: webLink, - conferenceJson: conferenceJson, - attachmentsJson: attachmentsJson, - isCancelled: isCancelled, - isDeleted: isDeleted, - rawJson: rawJson, - createdAtServer: createdAtServer, - updatedAtServer: updatedAtServer, - createdAtLocal: createdAtLocal, - updatedAtLocal: updatedAtLocal, - syncStatus: syncStatus, - baselineRawJson: baselineRawJson, + projectionRangeStartUtc: projectionRangeStartUtc, + projectionRangeEndUtc: projectionRangeEndUtc, + createdAtUtc: createdAtUtc, + updatedAtUtc: updatedAtUtc, rowid: rowid, ), createCompanionCallback: @@ -36540,131 +40517,77 @@ class $$CalendarEventsTableTableManager required String id, required String accountId, required String calendarSourceId, - required String provider, - required String providerCalendarId, - required String providerEventId, - Value davCollectionId = const Value.absent(), - Value davObjectId = const Value.absent(), - Value davComponentId = const Value.absent(), - Value icalUid = const Value.absent(), - Value recurrenceIdKey = const Value.absent(), - Value occurrenceKey = const Value.absent(), + required String feedFingerprint, + required String safeOrigin, + Value validatorTargetFingerprint = + const Value.absent(), + Value etag = const Value.absent(), + Value lastModified = const Value.absent(), + Value contentType = const Value.absent(), + required String snapshotIcsBody, + required String rawBodyHash, + required String semanticHash, + required String refreshMode, + Value serverRefreshIntervalSeconds = const Value.absent(), + required String nextRefreshAtUtc, + Value lastCheckedAtUtc = const Value.absent(), + Value lastSuccessfulSyncAtUtc = const Value.absent(), + Value lastChangedAtUtc = const Value.absent(), + Value consecutiveFailureCount = const Value.absent(), + Value lastFailureCode = const Value.absent(), + Value lastFailureHttpStatus = const Value.absent(), + Value generation = const Value.absent(), + Value parserVersion = const Value.absent(), Value projectionVersion = const Value.absent(), - Value providerRecurringEventId = const Value.absent(), - Value providerOriginalStartKey = const Value.absent(), - Value etagOrChangeKey = const Value.absent(), - Value status = const Value.absent(), - required String title, - Value description = const Value.absent(), - Value location = const Value.absent(), - Value allDay = const Value.absent(), - Value startDate = const Value.absent(), - Value startDateTime = const Value.absent(), - Value startTimeZone = const Value.absent(), - Value endDate = const Value.absent(), - Value endDateTime = const Value.absent(), - Value endTimeZone = const Value.absent(), - Value recurrenceJson = const Value.absent(), - Value remindersJson = const Value.absent(), - Value attendeesJson = const Value.absent(), - Value categoriesJson = const Value.absent(), - Value organizerJson = const Value.absent(), - Value creatorJson = const Value.absent(), - Value colorId = const Value.absent(), - Value colorHex = const Value.absent(), - Value visibility = const Value.absent(), - Value transparencyOrShowAs = const Value.absent(), - Value eventType = const Value.absent(), - Value webLink = const Value.absent(), - Value conferenceJson = const Value.absent(), - Value attachmentsJson = const Value.absent(), - Value isCancelled = const Value.absent(), - Value isDeleted = const Value.absent(), - Value rawJson = const Value.absent(), - Value createdAtServer = const Value.absent(), - Value updatedAtServer = const Value.absent(), - required int createdAtLocal, - required int updatedAtLocal, - Value syncStatus = const Value.absent(), - Value baselineRawJson = const Value.absent(), + Value projectionRangeStartUtc = const Value.absent(), + Value projectionRangeEndUtc = const Value.absent(), + required String createdAtUtc, + required String updatedAtUtc, Value rowid = const Value.absent(), - }) => CalendarEventsCompanion.insert( + }) => WebCalSubscriptionsCompanion.insert( id: id, accountId: accountId, calendarSourceId: calendarSourceId, - provider: provider, - providerCalendarId: providerCalendarId, - providerEventId: providerEventId, - davCollectionId: davCollectionId, - davObjectId: davObjectId, - davComponentId: davComponentId, - icalUid: icalUid, - recurrenceIdKey: recurrenceIdKey, - occurrenceKey: occurrenceKey, + feedFingerprint: feedFingerprint, + safeOrigin: safeOrigin, + validatorTargetFingerprint: validatorTargetFingerprint, + etag: etag, + lastModified: lastModified, + contentType: contentType, + snapshotIcsBody: snapshotIcsBody, + rawBodyHash: rawBodyHash, + semanticHash: semanticHash, + refreshMode: refreshMode, + serverRefreshIntervalSeconds: serverRefreshIntervalSeconds, + nextRefreshAtUtc: nextRefreshAtUtc, + lastCheckedAtUtc: lastCheckedAtUtc, + lastSuccessfulSyncAtUtc: lastSuccessfulSyncAtUtc, + lastChangedAtUtc: lastChangedAtUtc, + consecutiveFailureCount: consecutiveFailureCount, + lastFailureCode: lastFailureCode, + lastFailureHttpStatus: lastFailureHttpStatus, + generation: generation, + parserVersion: parserVersion, projectionVersion: projectionVersion, - providerRecurringEventId: providerRecurringEventId, - providerOriginalStartKey: providerOriginalStartKey, - etagOrChangeKey: etagOrChangeKey, - status: status, - title: title, - description: description, - location: location, - allDay: allDay, - startDate: startDate, - startDateTime: startDateTime, - startTimeZone: startTimeZone, - endDate: endDate, - endDateTime: endDateTime, - endTimeZone: endTimeZone, - recurrenceJson: recurrenceJson, - remindersJson: remindersJson, - attendeesJson: attendeesJson, - categoriesJson: categoriesJson, - organizerJson: organizerJson, - creatorJson: creatorJson, - colorId: colorId, - colorHex: colorHex, - visibility: visibility, - transparencyOrShowAs: transparencyOrShowAs, - eventType: eventType, - webLink: webLink, - conferenceJson: conferenceJson, - attachmentsJson: attachmentsJson, - isCancelled: isCancelled, - isDeleted: isDeleted, - rawJson: rawJson, - createdAtServer: createdAtServer, - updatedAtServer: updatedAtServer, - createdAtLocal: createdAtLocal, - updatedAtLocal: updatedAtLocal, - syncStatus: syncStatus, - baselineRawJson: baselineRawJson, + projectionRangeStartUtc: projectionRangeStartUtc, + projectionRangeEndUtc: projectionRangeEndUtc, + createdAtUtc: createdAtUtc, + updatedAtUtc: updatedAtUtc, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( e.readTable(table), - $$CalendarEventsTableReferences(db, table, e), + $$WebCalSubscriptionsTableReferences(db, table, e), ), ) .toList(), prefetchHooksCallback: - ({ - accountId = false, - calendarSourceId = false, - davCollectionId = false, - davObjectId = false, - davComponentId = false, - calendarEventAttendeesRefs = false, - calendarEventRemindersRefs = false, - }) { + ({accountId = false, calendarSourceId = false}) { return PrefetchHooks( db: db, - explicitlyWatchedTables: [ - if (calendarEventAttendeesRefs) db.calendarEventAttendees, - if (calendarEventRemindersRefs) db.calendarEventReminders, - ], + explicitlyWatchedTables: [], addJoins: < T extends TableManagerState< @@ -36687,10 +40610,10 @@ class $$CalendarEventsTableTableManager currentTable: table, currentColumn: table.accountId, referencedTable: - $$CalendarEventsTableReferences + $$WebCalSubscriptionsTableReferences ._accountIdTable(db), referencedColumn: - $$CalendarEventsTableReferences + $$WebCalSubscriptionsTableReferences ._accountIdTable(db) .id, ) @@ -36702,108 +40625,20 @@ class $$CalendarEventsTableTableManager currentTable: table, currentColumn: table.calendarSourceId, referencedTable: - $$CalendarEventsTableReferences + $$WebCalSubscriptionsTableReferences ._calendarSourceIdTable(db), referencedColumn: - $$CalendarEventsTableReferences + $$WebCalSubscriptionsTableReferences ._calendarSourceIdTable(db) .id, ) as T; } - if (davCollectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davCollectionId, - referencedTable: - $$CalendarEventsTableReferences - ._davCollectionIdTable(db), - referencedColumn: - $$CalendarEventsTableReferences - ._davCollectionIdTable(db) - .id, - ) - as T; - } - if (davObjectId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davObjectId, - referencedTable: - $$CalendarEventsTableReferences - ._davObjectIdTable(db), - referencedColumn: - $$CalendarEventsTableReferences - ._davObjectIdTable(db) - .id, - ) - as T; - } - if (davComponentId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davComponentId, - referencedTable: - $$CalendarEventsTableReferences - ._davComponentIdTable(db), - referencedColumn: - $$CalendarEventsTableReferences - ._davComponentIdTable(db) - .id, - ) - as T; - } return state; }, getPrefetchedDataCallback: (items) async { - return [ - if (calendarEventAttendeesRefs) - await $_getPrefetchedData< - CalendarEvent, - $CalendarEventsTable, - CalendarEventAttendee - >( - currentTable: table, - referencedTable: $$CalendarEventsTableReferences - ._calendarEventAttendeesRefsTable(db), - managerFromTypedResult: (p0) => - $$CalendarEventsTableReferences( - db, - table, - p0, - ).calendarEventAttendeesRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.calendarEventId == item.id, - ), - typedResults: items, - ), - if (calendarEventRemindersRefs) - await $_getPrefetchedData< - CalendarEvent, - $CalendarEventsTable, - CalendarEventReminder - >( - currentTable: table, - referencedTable: $$CalendarEventsTableReferences - ._calendarEventRemindersRefsTable(db), - managerFromTypedResult: (p0) => - $$CalendarEventsTableReferences( - db, - table, - p0, - ).calendarEventRemindersRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.calendarEventId == item.id, - ), - typedResults: items, - ), - ]; + return []; }, ); }, @@ -36811,27 +40646,19 @@ class $$CalendarEventsTableTableManager ); } -typedef $$CalendarEventsTableProcessedTableManager = +typedef $$WebCalSubscriptionsTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $CalendarEventsTable, - CalendarEvent, - $$CalendarEventsTableFilterComposer, - $$CalendarEventsTableOrderingComposer, - $$CalendarEventsTableAnnotationComposer, - $$CalendarEventsTableCreateCompanionBuilder, - $$CalendarEventsTableUpdateCompanionBuilder, - (CalendarEvent, $$CalendarEventsTableReferences), - CalendarEvent, - PrefetchHooks Function({ - bool accountId, - bool calendarSourceId, - bool davCollectionId, - bool davObjectId, - bool davComponentId, - bool calendarEventAttendeesRefs, - bool calendarEventRemindersRefs, - }) + $WebCalSubscriptionsTable, + WebCalSubscription, + $$WebCalSubscriptionsTableFilterComposer, + $$WebCalSubscriptionsTableOrderingComposer, + $$WebCalSubscriptionsTableAnnotationComposer, + $$WebCalSubscriptionsTableCreateCompanionBuilder, + $$WebCalSubscriptionsTableUpdateCompanionBuilder, + (WebCalSubscription, $$WebCalSubscriptionsTableReferences), + WebCalSubscription, + PrefetchHooks Function({bool accountId, bool calendarSourceId}) >; typedef $$CalendarEventAttendeesTableCreateCompanionBuilder = CalendarEventAttendeesCompanion Function({ @@ -39575,6 +43402,10 @@ class $AppDatabaseManager { $$CalendarSourcesTableTableManager(_db, _db.calendarSources); $$CalendarEventsTableTableManager get calendarEvents => $$CalendarEventsTableTableManager(_db, _db.calendarEvents); + $$IcalImportReceiptsTableTableManager get icalImportReceipts => + $$IcalImportReceiptsTableTableManager(_db, _db.icalImportReceipts); + $$WebCalSubscriptionsTableTableManager get webCalSubscriptions => + $$WebCalSubscriptionsTableTableManager(_db, _db.webCalSubscriptions); $$CalendarEventAttendeesTableTableManager get calendarEventAttendees => $$CalendarEventAttendeesTableTableManager( _db, diff --git a/lib/src/db/daos/pending_ops_dao.dart b/lib/src/db/daos/pending_ops_dao.dart index 3f236ed5..3d709bca 100644 --- a/lib/src/db/daos/pending_ops_dao.dart +++ b/lib/src/db/daos/pending_ops_dao.dart @@ -31,6 +31,20 @@ class PendingOpsDao extends DatabaseAccessor return query.go(); } + /// Deletes [snapshot] only when the queued payload still matches the + /// revision that a replayer actually sent. + Future deleteOpIfUnchanged(PendingOp snapshot) async { + final query = delete(pendingOps) + ..where( + (row) => + row.id.equals(snapshot.id) & + row.requestJson.equals(snapshot.requestJson) & + row.updatedAtUtc.equals(snapshot.updatedAtUtc) & + row.attemptCount.equals(snapshot.attemptCount), + ); + return await query.go() == 1; + } + Future getOp(String id) { final query = select(pendingOps)..where((row) => row.id.equals(id)); return query.getSingleOrNull(); @@ -77,4 +91,33 @@ class PendingOpsDao extends DatabaseAccessor ), ); } + + /// Records a failed attempt only if the queued payload has not been edited + /// since [snapshot] was read by the replayer. + Future updateAttemptIfUnchanged({ + required PendingOp snapshot, + required int attemptCount, + required DateTime nextAttemptAtUtc, + String? lastErrorCode, + String? lastErrorMessage, + }) async { + final query = update(pendingOps) + ..where( + (row) => + row.id.equals(snapshot.id) & + row.requestJson.equals(snapshot.requestJson) & + row.updatedAtUtc.equals(snapshot.updatedAtUtc) & + row.attemptCount.equals(snapshot.attemptCount), + ); + final updated = await query.write( + PendingOpsCompanion( + attemptCount: Value(attemptCount), + nextAttemptAtUtc: Value(nextAttemptAtUtc.toIso8601String()), + lastErrorCode: Value(lastErrorCode), + lastErrorMessage: Value(lastErrorMessage), + updatedAtUtc: Value(DateTime.now().toUtc().toIso8601String()), + ), + ); + return updated == 1; + } } diff --git a/lib/src/db/daos/sync_runs_dao.dart b/lib/src/db/daos/sync_runs_dao.dart index 60543110..c9329f9f 100644 --- a/lib/src/db/daos/sync_runs_dao.dart +++ b/lib/src/db/daos/sync_runs_dao.dart @@ -44,4 +44,17 @@ class SyncRunsDao extends DatabaseAccessor ..limit(limit); return query.get(); } + + Future latestSuccessfulRun(String accountId) { + final query = select(syncRuns) + ..where( + (row) => + row.accountId.equals(accountId) & + row.status.equals('success') & + row.finishedAtUtc.isNotNull(), + ) + ..orderBy([(row) => OrderingTerm.desc(row.startedAtUtc)]) + ..limit(1); + return query.getSingleOrNull(); + } } diff --git a/lib/src/db/migrations.dart b/lib/src/db/migrations.dart index d55627df..362d0a4c 100644 --- a/lib/src/db/migrations.dart +++ b/lib/src/db/migrations.dart @@ -2,7 +2,7 @@ import 'package:drift/drift.dart'; import 'app_database.dart'; -const latestSchemaVersion = 9; +const latestSchemaVersion = 13; /// A recoverable, non-secret diagnostic raised when an on-disk schema cannot /// be migrated without guessing remote identity or losing synchronized data. @@ -51,6 +51,18 @@ MigrationStrategy busyMaxMigrationStrategy(AppDatabase database) { if (from < 9) { await _migrateToV9(migrator, database); } + if (from < 10) { + await _migrateToV10(migrator, database); + } + if (from < 11) { + await _migrateToV11(migrator, database); + } + if (from < 12) { + await _migrateToV12(migrator, database); + } + if (from < 13) { + await _migrateToV13(migrator, database); + } await _createIndexes(database); await _verifyForeignKeys(database); }, @@ -60,6 +72,55 @@ MigrationStrategy busyMaxMigrationStrategy(AppDatabase database) { ); } +Future _migrateToV13(Migrator migrator, AppDatabase database) async { + await _verifyAccountIdentity(database); + await migrator.alterTable(TableMigration(database.accounts)); + if (await _hasTable(database, 'web_cal_subscriptions')) { + for (final column in [ + database.webCalSubscriptions.projectionRangeStartUtc, + database.webCalSubscriptions.projectionRangeEndUtc, + ]) { + await _addColumnIfMissing( + migrator, + database, + database.webCalSubscriptions, + column, + ); + } + } +} + +Future _migrateToV12(Migrator migrator, AppDatabase database) async { + await _addColumnIfMissing( + migrator, + database, + database.taskLists, + database.taskLists.remindersEnabled, + ); +} + +Future _migrateToV11(Migrator migrator, AppDatabase database) async { + // Rebuild Accounts so SQLite adopts the extended provider and credential + // constraints while preserving every existing row and identifier. + await migrator.alterTable(TableMigration(database.accounts)); + await migrator.createTable(database.icalImportReceipts); + await migrator.createTable(database.webCalSubscriptions); +} + +Future _migrateToV10(Migrator migrator, AppDatabase database) async { + for (final column in [ + database.calendarSources.dataOwner, + database.calendarSources.isRemovable, + ]) { + await _addColumnIfMissing( + migrator, + database, + database.calendarSources, + column, + ); + } +} + Future _migrateToV9(Migrator migrator, AppDatabase database) async { await _addColumnIfMissing( migrator, @@ -143,7 +204,7 @@ Future _migrateToV6(Migrator migrator, AppDatabase database) async { await _migrateGenericCursors(migrator, database); await _verifyPreservedCounts(database, preservedCounts); - await _verifyV6AccountIdentity(database); + await _verifyAccountIdentity(database); } Future _addProjectionLinks( @@ -369,14 +430,22 @@ Future _verifyPreservedCounts( } } -Future _verifyV6AccountIdentity(AppDatabase database) async { +Future _verifyAccountIdentity(AppDatabase database) async { final invalid = await database.customSelect(''' SELECT id FROM accounts - WHERE provider NOT IN ('google', 'microsoft', 'apple_icloud', 'nextcloud') + WHERE provider NOT IN ( + 'google', 'microsoft', 'apple_icloud', 'nextcloud', 'webcal' + ) OR trim(authority) = '' OR trim(provider_account_id) = '' - OR credential_kind NOT IN ( - 'oauth', 'apple_app_specific_password', 'nextcloud_app_password' + OR NOT ( + (provider IN ('google', 'microsoft') AND credential_kind = 'oauth') + OR (provider = 'apple_icloud' AND + credential_kind = 'apple_app_specific_password') + OR (provider = 'nextcloud' AND + credential_kind = 'nextcloud_app_password') + OR (provider = 'webcal' AND + credential_kind = 'webcal_subscription') ) LIMIT 1 ''').getSingleOrNull(); @@ -681,6 +750,12 @@ Future _createIndexes(AppDatabase database) async { 'dismissed_at_utc, snoozed_until_utc)', ); } + if (await _hasTable(database, 'web_cal_subscriptions')) { + await database.customStatement( + 'CREATE INDEX IF NOT EXISTS idx_webcal_subscriptions_due ' + 'ON web_cal_subscriptions(next_refresh_at_utc)', + ); + } } Future _addColumnIfMissing( diff --git a/lib/src/db/tables.dart b/lib/src/db/tables.dart index fab1abeb..faec3c87 100644 --- a/lib/src/db/tables.dart +++ b/lib/src/db/tables.dart @@ -29,9 +29,17 @@ class Accounts extends Table { @override List get customConstraints => const [ - "CHECK (provider IN ('google', 'microsoft', 'apple_icloud', 'nextcloud'))", + "CHECK (provider IN ('google', 'microsoft', 'apple_icloud', 'nextcloud', " + "'webcal'))", "CHECK (credential_kind IN ('oauth', 'apple_app_specific_password', " - "'nextcloud_app_password'))", + "'nextcloud_app_password', 'webcal_subscription'))", + "CHECK ((provider IN ('google', 'microsoft') AND credential_kind = 'oauth') " + "OR (provider = 'apple_icloud' AND credential_kind = " + "'apple_app_specific_password') " + "OR (provider = 'nextcloud' AND credential_kind = " + "'nextcloud_app_password') " + "OR (provider = 'webcal' AND credential_kind = " + "'webcal_subscription'))", 'CHECK (length(trim(authority)) > 0)', 'CHECK (length(trim(provider_account_id)) > 0)', ]; @@ -215,6 +223,8 @@ class TaskLists extends Table { BoolColumn get localDirty => boolean().withDefault(const Constant(false))(); BoolColumn get pendingDelete => boolean().withDefault(const Constant(false))(); + BoolColumn get remindersEnabled => + boolean().withDefault(const Constant(true))(); TextColumn get lastSyncedAtUtc => text().nullable()(); TextColumn get createdLocalAtUtc => text()(); TextColumn get updatedLocalAtUtc => text()(); @@ -403,6 +413,8 @@ class CalendarSources extends Table { TextColumn get colorId => text().nullable()(); TextColumn get timeZone => text().nullable()(); TextColumn get accessRole => text().nullable()(); + TextColumn get dataOwner => text().nullable()(); + BoolColumn get isRemovable => boolean().nullable()(); BoolColumn get isDeleted => boolean().withDefault(const Constant(false))(); TextColumn get rawJson => text().nullable()(); IntColumn get createdAtLocal => integer()(); @@ -482,6 +494,60 @@ class CalendarEvents extends Table { Set> get primaryKey => {id}; } +class IcalImportReceipts extends Table { + TextColumn get calendarSourceId => + text().references(CalendarSources, #id, onDelete: KeyAction.cascade)(); + TextColumn get icalUid => text()(); + TextColumn get eventId => text().nullable()(); + TextColumn get importedAtUtc => text()(); + + @override + Set> get primaryKey => {calendarSourceId, icalUid}; +} + +class WebCalSubscriptions extends Table { + TextColumn get id => text()(); + TextColumn get accountId => + text().references(Accounts, #id, onDelete: KeyAction.cascade)(); + TextColumn get calendarSourceId => + text().references(CalendarSources, #id, onDelete: KeyAction.cascade)(); + TextColumn get feedFingerprint => text().unique()(); + TextColumn get safeOrigin => text()(); + TextColumn get validatorTargetFingerprint => text().nullable()(); + TextColumn get etag => text().nullable()(); + TextColumn get lastModified => text().nullable()(); + TextColumn get contentType => text().nullable()(); + TextColumn get snapshotIcsBody => text()(); + TextColumn get rawBodyHash => text()(); + TextColumn get semanticHash => text()(); + TextColumn get refreshMode => text()(); + IntColumn get serverRefreshIntervalSeconds => integer().nullable()(); + TextColumn get nextRefreshAtUtc => text()(); + TextColumn get lastCheckedAtUtc => text().nullable()(); + TextColumn get lastSuccessfulSyncAtUtc => text().nullable()(); + TextColumn get lastChangedAtUtc => text().nullable()(); + IntColumn get consecutiveFailureCount => + integer().withDefault(const Constant(0))(); + TextColumn get lastFailureCode => text().nullable()(); + IntColumn get lastFailureHttpStatus => integer().nullable()(); + IntColumn get generation => integer().withDefault(const Constant(1))(); + IntColumn get parserVersion => integer().withDefault(const Constant(1))(); + IntColumn get projectionVersion => integer().withDefault(const Constant(1))(); + TextColumn get projectionRangeStartUtc => text().nullable()(); + TextColumn get projectionRangeEndUtc => text().nullable()(); + TextColumn get createdAtUtc => text()(); + TextColumn get updatedAtUtc => text()(); + + @override + Set> get primaryKey => {id}; + + @override + List>> get uniqueKeys => [ + {accountId}, + {calendarSourceId}, + ]; +} + class CalendarEventAttendees extends Table { TextColumn get id => text()(); TextColumn get calendarEventId => diff --git a/lib/src/demo/demo_profile.dart b/lib/src/demo/demo_profile.dart index a5b19358..1e07ed81 100644 --- a/lib/src/demo/demo_profile.dart +++ b/lib/src/demo/demo_profile.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:desktop_notifications/desktop_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; @@ -105,12 +104,12 @@ class BusyMaxDemoProfile { ), syncSchedulerProvider.overrideWith((ref) { final scheduler = AllAccountsSyncScheduler( - listSignedInAccounts: () async => const [], + listSyncEligibleAccounts: () async => const [], syncAccount: (_) async {}, onSyncFailure: (_) async {}, interval: Duration.zero, ); - ref.onDispose(scheduler.stop); + ref.onDispose(() => unawaited(scheduler.dispose())); return scheduler; }), notificationSchedulerProvider.overrideWith((ref) { @@ -207,12 +206,12 @@ class DemoDesktopNotificationBackend implements DesktopNotificationBackend { @override Future close() async {} + @override + Future cancel(String stableId) async {} + @override Future notify( - String summary, { - String body = '', - List hints = const [], - List actions = const [], + BusyMaxNotificationRequest request, { DesktopNotificationActionHandler? onAction, }) async {} } diff --git a/lib/src/features/accounts/data/accounts_repository.dart b/lib/src/features/accounts/data/accounts_repository.dart index 3f125db4..86bcb86f 100644 --- a/lib/src/features/accounts/data/accounts_repository.dart +++ b/lib/src/features/accounts/data/accounts_repository.dart @@ -42,12 +42,19 @@ class AccountEntity { }); factory AccountEntity.fromRow(Account row) { + final provider = BusyProviderCodec.requireStorageValue(row.provider); + final credentialKind = _credentialKindFromStorage(row.credentialKind); + if (!credentialKindMatchesProvider(provider, credentialKind)) { + throw const SecretStoreCorruptException( + 'Stored account provider and credential kind do not match.', + ); + } return AccountEntity( id: row.id, - provider: BusyProviderCodec.requireStorageValue(row.provider), + provider: provider, authority: row.authority, providerAccountId: row.providerAccountId, - credentialKind: _credentialKindFromStorage(row.credentialKind), + credentialKind: credentialKind, providerProfileVersion: row.providerProfileVersion, displayName: row.displayName, email: row.email, @@ -85,6 +92,14 @@ class AccountEntity { AccountConnectionStateCodec.parse(authState); bool get isSignedIn => authState == accountAuthStateSignedIn; + bool get isSubscription => provider == BusyProvider.webCal; + bool get isAuthenticationAccount => !isSubscription; + bool get isTaskCapable => + !isSubscription && tasksEnabled && provider != BusyProvider.appleICloud; + bool get isScheduleVisible => calendarsEnabled; + bool get isSyncEligible => + authState == accountAuthStateSignedIn || + authState == accountAuthStateTemporarilyUnavailable; bool get needsReconnect => authState == accountAuthStateReauthRequired; bool get hasConnectionIssue => authState != accountAuthStateSignedIn; @@ -164,7 +179,11 @@ class AccountsRepository { Stream> watchVisibleAccounts() { final query = _database.select(_database.accounts) - ..where((row) => row.authState.isIn([...accountCachedAvailableStates])) + ..where( + (row) => + row.authState.isIn([...accountCachedAvailableStates]) & + row.provider.equals(BusyProvider.webCal.storageValue).not(), + ) ..orderBy([ (row) => OrderingTerm.asc(row.provider), (row) => OrderingTerm.asc(row.displayName), @@ -177,7 +196,11 @@ class AccountsRepository { Future> listSignedInAccounts() async { final query = _database.select(_database.accounts) - ..where((row) => row.authState.equals(accountAuthStateSignedIn)) + ..where( + (row) => + row.authState.equals(accountAuthStateSignedIn) & + row.provider.equals(BusyProvider.webCal.storageValue).not(), + ) ..orderBy([ (row) => OrderingTerm.asc(row.provider), (row) => OrderingTerm.asc(row.displayName), @@ -205,7 +228,11 @@ class AccountsRepository { Future> listVisibleAccounts() async { final query = _database.select(_database.accounts) - ..where((row) => row.authState.isIn(accountCachedAvailableStates)) + ..where( + (row) => + row.authState.isIn(accountCachedAvailableStates) & + row.provider.equals(BusyProvider.webCal.storageValue).not(), + ) ..orderBy([ (row) => OrderingTerm.asc(row.provider), (row) => OrderingTerm.asc(row.displayName), @@ -247,7 +274,14 @@ class AccountsRepository { providerAccountId ?? id, ); final resolvedCredentialKind = - credentialKind ?? _defaultCredentialKind(provider); + credentialKind ?? credentialKindForProvider(provider); + if (!credentialKindMatchesProvider(provider, resolvedCredentialKind)) { + throw ArgumentError.value( + resolvedCredentialKind, + 'credentialKind', + 'The credential kind does not match the account provider.', + ); + } final existing = await (_database.select( _database.accounts, )..where((account) => account.id.equals(id))).getSingleOrNull(); @@ -333,17 +367,11 @@ class AccountsRepository { String _now() => _nowUtc().toIso8601String(); } -CredentialKind _defaultCredentialKind(BusyProvider provider) => - switch (provider) { - BusyProvider.google || BusyProvider.microsoft => CredentialKind.oauth, - BusyProvider.appleICloud => CredentialKind.appleAppSpecificPassword, - BusyProvider.nextcloud => CredentialKind.nextcloudAppPassword, - }; - CredentialKind _credentialKindFromStorage(String value) => switch (value) { 'oauth' => CredentialKind.oauth, 'apple_app_specific_password' => CredentialKind.appleAppSpecificPassword, 'nextcloud_app_password' => CredentialKind.nextcloudAppPassword, + 'webcal_subscription' => CredentialKind.webCalSubscription, _ => throw SecretStoreCorruptException( 'Unsupported stored credential kind for account metadata.', ), diff --git a/lib/src/features/accounts/domain/account_collection_creation_capabilities.dart b/lib/src/features/accounts/domain/account_collection_creation_capabilities.dart new file mode 100644 index 00000000..d1dd103d --- /dev/null +++ b/lib/src/features/accounts/domain/account_collection_creation_capabilities.dart @@ -0,0 +1,98 @@ +import '../../../providers/busy_provider.dart'; +import '../../connectivity/network_connectivity_service.dart'; +import '../data/accounts_repository.dart'; + +enum CalendarCollectionCreationMode { + unavailable, + cloudPendingOperation, + nextcloudDav, +} + +enum TaskListCreationMode { unavailable, cloudPendingOperation, nextcloudDav } + +/// Account-level collection creation policy. +/// +/// These modes describe how a collection is created. They are intentionally +/// separate from item-level write capabilities such as creating an event or a +/// task inside an existing collection. +final class AccountCollectionCreationCapabilities { + const AccountCollectionCreationCapabilities({ + required this.calendarMode, + required this.taskListMode, + required this.calendarActionEnabled, + required this.taskListActionEnabled, + }); + + factory AccountCollectionCreationCapabilities.resolve({ + required AccountEntity account, + required NetworkAvailability networkAvailability, + bool calendarCreationRunning = false, + bool taskListCreationRunning = false, + }) { + final providerModes = accountCollectionCreationModes(account.provider); + final calendarMode = account.calendarsEnabled + ? providerModes.calendarMode + : CalendarCollectionCreationMode.unavailable; + final taskListMode = account.tasksEnabled + ? providerModes.taskListMode + : TaskListCreationMode.unavailable; + final connected = account.isSignedIn; + final nextcloudOnline = networkAvailability != NetworkAvailability.offline; + return AccountCollectionCreationCapabilities( + calendarMode: calendarMode, + taskListMode: taskListMode, + calendarActionEnabled: + calendarMode != CalendarCollectionCreationMode.unavailable && + connected && + !calendarCreationRunning && + (calendarMode != CalendarCollectionCreationMode.nextcloudDav || + nextcloudOnline), + taskListActionEnabled: + taskListMode != TaskListCreationMode.unavailable && + connected && + !taskListCreationRunning && + (taskListMode != TaskListCreationMode.nextcloudDav || + nextcloudOnline), + ); + } + + final CalendarCollectionCreationMode calendarMode; + final TaskListCreationMode taskListMode; + final bool calendarActionEnabled; + final bool taskListActionEnabled; + + bool get supportsCalendarCreation => + calendarMode != CalendarCollectionCreationMode.unavailable; + bool get supportsTaskListCreation => + taskListMode != TaskListCreationMode.unavailable; + bool get hasActions => supportsCalendarCreation || supportsTaskListCreation; +} + +final class AccountCollectionCreationModes { + const AccountCollectionCreationModes({ + required this.calendarMode, + required this.taskListMode, + }); + + final CalendarCollectionCreationMode calendarMode; + final TaskListCreationMode taskListMode; +} + +AccountCollectionCreationModes accountCollectionCreationModes( + BusyProvider provider, +) => switch (provider) { + BusyProvider.google || + BusyProvider.microsoft => const AccountCollectionCreationModes( + calendarMode: CalendarCollectionCreationMode.cloudPendingOperation, + taskListMode: TaskListCreationMode.cloudPendingOperation, + ), + BusyProvider.nextcloud => const AccountCollectionCreationModes( + calendarMode: CalendarCollectionCreationMode.nextcloudDav, + taskListMode: TaskListCreationMode.nextcloudDav, + ), + BusyProvider.appleICloud || + BusyProvider.webCal => const AccountCollectionCreationModes( + calendarMode: CalendarCollectionCreationMode.unavailable, + taskListMode: TaskListCreationMode.unavailable, + ), +}; diff --git a/lib/src/features/auth/data/auth_repository.dart b/lib/src/features/auth/data/auth_repository.dart index a7bc0e0b..194802dd 100644 --- a/lib/src/features/auth/data/auth_repository.dart +++ b/lib/src/features/auth/data/auth_repository.dart @@ -201,6 +201,9 @@ class AuthRepository { // DAV credentials are cleared through SecretStore once the account is // moved to reauthentication-required state. break; + case BusyProvider.webCal: + // Subscriptions never participate in authentication recovery. + break; } } diff --git a/lib/src/features/auth/presentation/sign_in_screen.dart b/lib/src/features/auth/presentation/sign_in_screen.dart index fa7275aa..57ff368e 100644 --- a/lib/src/features/auth/presentation/sign_in_screen.dart +++ b/lib/src/features/auth/presentation/sign_in_screen.dart @@ -26,6 +26,7 @@ import 'package:busymax/src/core/secrets/secret_store.dart'; import '../../../l10n/l10n.dart'; import '../../../microsoft_todo/oauth/microsoft_oauth_service.dart'; import '../../../platform/linux_header_bar_service.dart'; +import '../../../platform/linux_header_bar_provider.dart'; import '../../sync/sync_auth_error.dart'; enum _OnboardingStep { accounts, preferences } @@ -179,6 +180,8 @@ class _SignInScreenState extends ConsumerState { ), onAddNextcloud: () => _signIn(_OnboardingProvider.nextcloud), + onAddSubscription: () => + context.go('/settings?page=accounts'), onCancelSignIn: _cancelSignIn, ), _OnboardingStep.preferences => @@ -479,6 +482,7 @@ class _AccountsOnboardingStep extends StatelessWidget { required this.onAddMicrosoft, required this.onAddApple, required this.onAddNextcloud, + required this.onAddSubscription, required this.onCancelSignIn, }); @@ -495,6 +499,7 @@ class _AccountsOnboardingStep extends StatelessWidget { final VoidCallback onAddMicrosoft; final VoidCallback onAddApple; final VoidCallback onAddNextcloud; + final VoidCallback onAddSubscription; final VoidCallback onCancelSignIn; @override @@ -564,6 +569,16 @@ class _AccountsOnboardingStep extends StatelessWidget { tooltip: l10n.addNextcloudAccount, onPressed: onAddNextcloud, ), + const SizedBox(height: BusyMaxSpacing.md), + _ProviderSignInButton( + label: l10n.addCalendarSubscription, + loadingLabel: l10n.addCalendarSubscription, + configured: true, + enabled: !isSigningIn, + loading: false, + tooltip: l10n.calendarSubscriptionsDescription, + onPressed: onAddSubscription, + ), if (accounts.isNotEmpty) ...[ const SizedBox(height: BusyMaxSpacing.lg), _SignedInAccountsSummary(accounts: accounts), diff --git a/lib/src/features/calendar/data/calendar_collection_creation_service.dart b/lib/src/features/calendar/data/calendar_collection_creation_service.dart new file mode 100644 index 00000000..15ddcf35 --- /dev/null +++ b/lib/src/features/calendar/data/calendar_collection_creation_service.dart @@ -0,0 +1,118 @@ +import '../../../dav/mutation/dav_calendar_collection_mutation_service.dart'; +import '../../accounts/data/accounts_repository.dart'; +import '../../accounts/domain/account_collection_creation_capabilities.dart'; +import 'calendar_repository.dart'; + +enum CalendarCollectionCreationOutcome { + queued, + created, + createdRefreshPending, +} + +final class CalendarCollectionCreationResult { + const CalendarCollectionCreationResult({ + required this.outcome, + this.localSourceId, + }); + + final CalendarCollectionCreationOutcome outcome; + final String? localSourceId; +} + +final class CalendarCollectionCreationException implements Exception { + const CalendarCollectionCreationException(this.safeMessage); + + final String safeMessage; + + @override + String toString() => safeMessage; +} + +typedef DavCalendarMutationClientForAccount = + DavCalendarCollectionMutationClient Function(String accountId); + +abstract interface class CalendarCollectionCreator { + Future createCalendar({ + required String accountId, + required String title, + }); +} + +/// Routes account-level calendar creation without mixing cloud pending +/// operations with DAV collection mutations. +final class CalendarCollectionCreationService + implements CalendarCollectionCreator { + CalendarCollectionCreationService({ + required AccountsRepository accountsRepository, + required CalendarRepository calendarRepository, + required DavCalendarMutationClientForAccount davClientForAccount, + required void Function(String accountId) requestCloudSynchronization, + }) : _accountsRepository = accountsRepository, + _calendarRepository = calendarRepository, + _davClientForAccount = davClientForAccount, + _requestCloudSynchronization = requestCloudSynchronization; + + final AccountsRepository _accountsRepository; + final CalendarRepository _calendarRepository; + final DavCalendarMutationClientForAccount _davClientForAccount; + final void Function(String accountId) _requestCloudSynchronization; + + @override + Future createCalendar({ + required String accountId, + required String title, + }) async { + final trimmedTitle = title.trim(); + if (trimmedTitle.isEmpty) { + throw ArgumentError.value(title, 'title', 'A calendar name is required.'); + } + final account = await _accountsRepository.accountById(accountId); + if (account == null || !account.calendarsEnabled || !account.isSignedIn) { + throw const CalendarCollectionCreationException( + 'Calendar creation is unavailable for this account.', + ); + } + final mode = accountCollectionCreationModes(account.provider).calendarMode; + return switch (mode) { + CalendarCollectionCreationMode.cloudPendingOperation => + _createCloudCalendar(accountId, trimmedTitle), + CalendarCollectionCreationMode.nextcloudDav => _createNextcloudCalendar( + accountId, + trimmedTitle, + ), + CalendarCollectionCreationMode.unavailable => + throw const CalendarCollectionCreationException( + 'This provider does not support calendar creation.', + ), + }; + } + + Future _createCloudCalendar( + String accountId, + String title, + ) async { + final sourceId = await _calendarRepository.createLocalSource( + accountId: accountId, + summary: title, + ); + _requestCloudSynchronization(accountId); + return CalendarCollectionCreationResult( + outcome: CalendarCollectionCreationOutcome.queued, + localSourceId: sourceId, + ); + } + + Future _createNextcloudCalendar( + String accountId, + String title, + ) async { + final result = await _davClientForAccount( + accountId, + ).createEventCalendar(title); + return CalendarCollectionCreationResult( + outcome: result.refreshPending + ? CalendarCollectionCreationOutcome.createdRefreshPending + : CalendarCollectionCreationOutcome.created, + ); + } +} diff --git a/lib/src/features/calendar/data/calendar_event_detail.dart b/lib/src/features/calendar/data/calendar_event_detail.dart index 2d881477..53e0e90b 100644 --- a/lib/src/features/calendar/data/calendar_event_detail.dart +++ b/lib/src/features/calendar/data/calendar_event_detail.dart @@ -43,6 +43,9 @@ final class CalendarEventDetail { required this.categories, required this.organizer, required this.creator, + required this.guestsCanModify, + required this.guestsCanInviteOthers, + required this.locked, required this.colorId, required this.colorHex, required this.visibility, @@ -63,6 +66,8 @@ final class CalendarEventDetail { }); factory CalendarEventDetail.fromRow(CalendarEvent row) { + final raw = _decodeJson(row.rawJson); + final rawMap = raw is Map ? Map.from(raw) : const {}; return CalendarEventDetail( id: row.id, accountId: row.accountId, @@ -97,6 +102,9 @@ final class CalendarEventDetail { categories: _decodeJson(row.categoriesJson), organizer: _decodeJson(row.organizerJson), creator: _decodeJson(row.creatorJson), + guestsCanModify: rawMap['guestsCanModify'] == true, + guestsCanInviteOthers: rawMap['guestsCanInviteOthers'] != false, + locked: rawMap['locked'] == true, colorId: row.colorId, colorHex: row.colorHex, visibility: row.visibility, @@ -107,7 +115,7 @@ final class CalendarEventDetail { attachments: _decodeJson(row.attachmentsJson), isCancelled: row.isCancelled, isDeleted: row.isDeleted, - raw: _decodeJson(row.rawJson), + raw: raw, createdAtServer: row.createdAtServer, updatedAtServer: row.updatedAtServer, createdAtLocal: row.createdAtLocal, @@ -150,6 +158,9 @@ final class CalendarEventDetail { final Object? categories; final Object? organizer; final Object? creator; + final bool guestsCanModify; + final bool guestsCanInviteOthers; + final bool locked; final String? colorId; final String? colorHex; final String? visibility; diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index 46ea9310..25bf37fd 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -4,8 +4,12 @@ import 'package:busymax/src/providers/busy_provider.dart'; import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; +import '../../../calendar_providers/calendar_colors.dart'; +import '../../../calendar_providers/calendar_create_identity.dart'; import '../../../calendar_providers/calendar_mutation.dart'; +import '../../../calendar_providers/calendar_provider_capabilities.dart'; import '../../../calendar_providers/calendar_sync_dto.dart'; +import '../../../core/time/provider_date_time.dart'; import '../../../dav/ical/ical_document.dart'; import '../../../dav/ical/ical_semantics.dart'; import '../../../dav/mutation/dav_mutation_patch.dart'; @@ -13,10 +17,21 @@ import '../../../dav/mutation/dav_pending_operations.dart'; import '../../../dav/mutation/dav_projection_mutations.dart'; 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 '../../accounts/domain/account_collection_creation_capabilities.dart'; import '../../notifications/notification_schedule_service.dart'; +import '../../recurrence/domain/event_recurrence_codec.dart'; +import '../domain/event_move_policy.dart'; import '../presentation/event_editor_draft.dart'; import 'calendar_event_detail.dart'; +typedef CalendarEventRecoveryFetcher = + Future Function({ + required String calendarId, + required String eventId, + }); + class CalendarSourceEntity { const CalendarSourceEntity({ required this.id, @@ -36,11 +51,19 @@ class CalendarSourceEntity { this.colorId, this.timeZone, this.accessRole, + this.dataOwner, + this.isRemovable, + this.authenticatedAccountEmail, + this.pendingCreate = false, this.davCollectionId, this.allowedConferenceSolutions = const [], }); - factory CalendarSourceEntity.fromRow(CalendarSource row) { + factory CalendarSourceEntity.fromRow( + CalendarSource row, { + String? authenticatedAccountEmail, + bool pendingCreate = false, + }) { return CalendarSourceEntity( id: row.id, accountId: row.accountId, @@ -59,6 +82,10 @@ class CalendarSourceEntity { colorId: row.colorId, timeZone: row.timeZone, accessRole: row.accessRole, + dataOwner: row.dataOwner, + isRemovable: row.isRemovable, + authenticatedAccountEmail: authenticatedAccountEmail, + pendingCreate: pendingCreate, davCollectionId: row.davCollectionId, allowedConferenceSolutions: _conferenceSolutions(row.rawJson), ); @@ -81,13 +108,36 @@ class CalendarSourceEntity { final String? colorId; final String? timeZone; final String? accessRole; + final String? dataOwner; + final bool? isRemovable; + final String? authenticatedAccountEmail; + final bool pendingCreate; final String? davCollectionId; final List allowedConferenceSolutions; CalendarSourceCapabilities get capabilities => CalendarSourceCapabilities.fromSource(this); + + bool get isCurrentGoogleDataOwner { + if (provider != BusyProvider.google) return false; + final owner = dataOwner?.trim().toLowerCase(); + final identity = authenticatedAccountEmail?.trim().toLowerCase(); + return owner != null && + owner.isNotEmpty && + identity != null && + identity.isNotEmpty && + owner == identity; + } + + bool get hasAuthenticatedGoogleIdentity => + provider == BusyProvider.google && + authenticatedAccountEmail?.trim().isNotEmpty == true; } +enum CalendarRenameMode { unavailable, global, personal } + +enum CalendarRemovalMode { unavailable, delete, removeFromList } + /// The event operations currently permitted by a calendar source. /// /// Keeping this policy with the source model gives every presentation surface @@ -99,14 +149,65 @@ class CalendarSourceCapabilities { required this.canCreateEvents, required this.canEditEvents, required this.canDeleteEvents, + required this.canRenameCalendar, + required this.canDeleteCalendar, + required this.canChangeCalendarColor, + required this.renameMode, + required this.removalMode, }); factory CalendarSourceCapabilities.fromSource(CalendarSourceEntity source) { - final writable = !source.readOnly && !source.isDeleted; + final available = !source.isDeleted; + final writable = !source.readOnly && available; + final management = calendarManagementCapabilities(source.provider); + final renameMode = switch (source.provider) { + BusyProvider.google || BusyProvider.microsoft + when available && source.pendingCreate => + CalendarRenameMode.global, + BusyProvider.google when available => + source.primaryCalendar || source.isCurrentGoogleDataOwner + ? CalendarRenameMode.global + : CalendarRenameMode.personal, + BusyProvider.microsoft when writable => CalendarRenameMode.global, + _ => CalendarRenameMode.unavailable, + }; + final removalMode = switch (source.provider) { + BusyProvider.google || BusyProvider.microsoft + when available && source.pendingCreate => + CalendarRemovalMode.delete, + BusyProvider.google + when available && + !source.primaryCalendar && + source.isCurrentGoogleDataOwner => + CalendarRemovalMode.delete, + BusyProvider.google + when available && + !source.primaryCalendar && + source.hasAuthenticatedGoogleIdentity && + source.dataOwner?.trim().isNotEmpty == true => + CalendarRemovalMode.removeFromList, + BusyProvider.microsoft + when available && + !source.primaryCalendar && + source.isRemovable == true => + CalendarRemovalMode.delete, + _ => CalendarRemovalMode.unavailable, + }; return CalendarSourceCapabilities( canCreateEvents: writable, canEditEvents: writable, canDeleteEvents: writable, + canRenameCalendar: + management.supportsRename && + renameMode != CalendarRenameMode.unavailable, + canDeleteCalendar: + management.supportsDelete && + removalMode == CalendarRemovalMode.delete, + canChangeCalendarColor: + management.supportsColor && + (source.provider == BusyProvider.google ? available : writable), + renameMode: renameMode, + removalMode: removalMode, ); } @@ -114,36 +215,61 @@ class CalendarSourceCapabilities { canCreateEvents: false, canEditEvents: false, canDeleteEvents: false, + canRenameCalendar: false, + canDeleteCalendar: false, + canChangeCalendarColor: false, + renameMode: CalendarRenameMode.unavailable, + removalMode: CalendarRemovalMode.unavailable, ); final bool canCreateEvents; final bool canEditEvents; final bool canDeleteEvents; + final bool canRenameCalendar; + final bool canDeleteCalendar; + final bool canChangeCalendarColor; + final CalendarRenameMode renameMode; + final CalendarRemovalMode removalMode; + + bool get canRemoveCalendar => removalMode != CalendarRemovalMode.unavailable; } enum CalendarMutationOperation { + createCalendar, createEvent, editEvent, + moveEvent, deleteEvent, renameCalendar, + changeCalendarColor, deleteCalendar, + removeCalendar, } class CalendarMutationNotAllowed implements Exception { const CalendarMutationNotAllowed({ required this.operation, required this.sourceId, + this.reason = CalendarMutationDenialReason.capability, }); final CalendarMutationOperation operation; final String sourceId; + final CalendarMutationDenialReason reason; @override String toString() { - return 'CalendarMutationNotAllowed(${operation.name}, source: $sourceId)'; + return 'CalendarMutationNotAllowed(' + '${operation.name}, source: $sourceId, reason: ${reason.name})'; } } +enum CalendarMutationDenialReason { + capability, + pendingChanges, + destinationPendingCreate, +} + List writableCalendarSources( Iterable sources, ) { @@ -175,16 +301,39 @@ class CalendarRepository { if (accountIds.isEmpty) { return Stream.value(const []); } - final query = _database.select(_database.calendarSources) - ..where( - (row) => row.accountId.isIn(accountIds) & row.isDeleted.equals(false), - ) - ..orderBy([ - (row) => OrderingTerm.asc(row.accountId), - (row) => OrderingTerm.asc(row.summary), - ]); + final query = _database.select(_database.calendarSources).join([ + innerJoin( + _database.accounts, + _database.accounts.id.equalsExp(_database.calendarSources.accountId), + ), + leftOuterJoin( + _database.pendingOps, + _database.pendingOps.calendarSourceId.equalsExp( + _database.calendarSources.id, + ) & + _database.pendingOps.operationType.equals('calendar.create') & + _database.pendingOps.state.equals('pending'), + ), + ]); + query.where( + _database.calendarSources.accountId.isIn(accountIds) & + _database.calendarSources.isDeleted.equals(false), + ); + query.orderBy([ + OrderingTerm.asc(_database.calendarSources.accountId), + OrderingTerm.asc(_database.calendarSources.summary), + ]); return query.watch().map( - (rows) => rows.map(CalendarSourceEntity.fromRow).toList(), + (rows) => [ + for (final result in rows) + CalendarSourceEntity.fromRow( + result.readTable(_database.calendarSources), + authenticatedAccountEmail: result + .readTable(_database.accounts) + .email, + pendingCreate: result.readTableOrNull(_database.pendingOps) != null, + ), + ], ); } @@ -194,16 +343,35 @@ class CalendarRepository { if (accountIds.isEmpty) { return const []; } - final query = _database.select(_database.calendarSources) - ..where( - (row) => - row.accountId.isIn(accountIds) & - row.selected.equals(true) & - row.hidden.equals(false) & - row.isDeleted.equals(false), - ); + final query = _database.select(_database.calendarSources).join([ + innerJoin( + _database.accounts, + _database.accounts.id.equalsExp(_database.calendarSources.accountId), + ), + leftOuterJoin( + _database.pendingOps, + _database.pendingOps.calendarSourceId.equalsExp( + _database.calendarSources.id, + ) & + _database.pendingOps.operationType.equals('calendar.create') & + _database.pendingOps.state.equals('pending'), + ), + ]); + query.where( + _database.calendarSources.accountId.isIn(accountIds) & + _database.calendarSources.selected.equals(true) & + _database.calendarSources.hidden.equals(false) & + _database.calendarSources.isDeleted.equals(false), + ); final rows = await query.get(); - return rows.map(CalendarSourceEntity.fromRow).toList(); + return [ + for (final result in rows) + CalendarSourceEntity.fromRow( + result.readTable(_database.calendarSources), + authenticatedAccountEmail: result.readTable(_database.accounts).email, + pendingCreate: result.readTableOrNull(_database.pendingOps) != null, + ), + ]; } Future loadEventDetail(String eventId) async { @@ -213,6 +381,17 @@ class CalendarRepository { return row == null ? null : CalendarEventDetail.fromRow(row); } + Future _sourceEntity(CalendarSource source) async { + final account = await (_database.select( + _database.accounts, + )..where((row) => row.id.equals(source.accountId))).getSingle(); + return CalendarSourceEntity.fromRow( + source, + authenticatedAccountEmail: account.email, + pendingCreate: await _pendingCalendarCreate(source.id) != null, + ); + } + Future setSourceSelected(String sourceId, bool selected) async { await (_database.update( _database.calendarSources, @@ -242,41 +421,199 @@ class CalendarRepository { await _onNotificationScheduleChanged?.call(); } + Future createLocalSource({ + required String accountId, + required String summary, + }) async { + final title = summary.trim(); + if (title.isEmpty) { + throw ArgumentError.value(summary, 'summary', 'Calendar title is empty.'); + } + final account = await (_database.select( + _database.accounts, + )..where((row) => row.id.equals(accountId))).getSingle(); + final provider = BusyProviderCodec.requireStorageValue(account.provider); + if (accountCollectionCreationModes(provider).calendarMode != + CalendarCollectionCreationMode.cloudPendingOperation) { + throw CalendarMutationNotAllowed( + operation: CalendarMutationOperation.createCalendar, + sourceId: accountId, + ); + } + + final uuid = const Uuid(); + final providerCalendarId = 'local:${uuid.v4()}'; + final id = sourceId( + accountId: accountId, + provider: provider, + providerCalendarId: providerCalendarId, + ); + final operationId = uuid.v4(); + final now = _now(); + final nowUtc = now.toUtc().toIso8601String(); + await _database.transaction(() async { + await _database + .into(_database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: id, + accountId: accountId, + provider: provider.storageValue, + providerCalendarId: providerCalendarId, + summary: title, + rawJson: Value( + jsonEncode({ + 'id': providerCalendarId, + 'summary': title, + '_localCreated': true, + }), + ), + createdAtLocal: now.millisecondsSinceEpoch, + updatedAtLocal: now.millisecondsSinceEpoch, + ), + ); + await _database.pendingOpsDao.enqueue( + PendingOpsCompanion.insert( + id: operationId, + accountId: accountId, + provider: Value(provider.storageValue), + entityType: 'calendar', + operation: 'create', + operationType: const Value('calendar.create'), + calendarSourceId: Value(id), + providerCalendarId: Value(providerCalendarId), + localTempId: Value(providerCalendarId), + requestJson: jsonEncode({'summary': title}), + createdAtUtc: nowUtc, + updatedAtUtc: nowUtc, + ), + ); + }); + return id; + } + Future renameLocalSource(String sourceId, String summary) async { final source = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).getSingle(); - _requireWritableSource( + final entity = await _sourceEntity(source); + _requireCalendarSourceCapability( source, operation: CalendarMutationOperation.renameCalendar, + allowed: entity.capabilities.canRenameCalendar, ); - final now = _now().millisecondsSinceEpoch; - final nowUtc = DateTime.now().toUtc().toIso8601String(); + final title = summary.trim(); + if (title.isEmpty) { + throw ArgumentError.value(summary, 'summary', 'Calendar title is empty.'); + } + final now = _now(); + final nowUtc = now.toUtc().toIso8601String(); + final createOp = await _pendingCalendarCreate(source.id); await _database.transaction(() async { await (_database.update( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).write( CalendarSourcesCompanion( - summary: Value(summary), - updatedAtLocal: Value(now), + summary: Value(title), + updatedAtLocal: Value(now.millisecondsSinceEpoch), ), ); - await _database.pendingOpsDao.enqueue( - PendingOpsCompanion.insert( - id: const Uuid().v4(), - accountId: source.accountId, - provider: Value(source.provider), - entityType: 'calendar', - operation: 'patch', - operationType: const Value('calendar.patch'), - calendarSourceId: Value(source.id), - providerCalendarId: Value(source.providerCalendarId), - requestJson: jsonEncode({'summary': summary}), - baselineRawJson: Value(source.rawJson), - createdAtUtc: nowUtc, - updatedAtUtc: nowUtc, + if (createOp != null) { + final request = _calendarPendingRequest(createOp)..['summary'] = title; + await (_database.update( + _database.pendingOps, + )..where((row) => row.id.equals(createOp.id))).write( + PendingOpsCompanion( + requestJson: Value(jsonEncode(request)), + updatedAtUtc: Value(nowUtc), + ), + ); + } else { + await _enqueueOrMergeCalendarPatch( + source, + request: { + 'summary': title, + calendarMutationScopeKey: + entity.capabilities.renameMode == CalendarRenameMode.personal + ? calendarMutationScopePersonal + : calendarMutationScopeGlobal, + }, + nowUtc: nowUtc, + ); + } + }); + } + + Future setSourceColor( + String sourceId, + CalendarColorChoice choice, + ) async { + final source = await (_database.select( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).getSingle(); + final entity = await _sourceEntity(source); + _requireCalendarSourceCapability( + source, + operation: CalendarMutationOperation.changeCalendarColor, + allowed: entity.capabilities.canChangeCalendarColor, + ); + final supportedChoice = calendarColorChoices(entity.provider).any( + (candidate) => + candidate.providerValue == choice.providerValue && + candidate.backgroundColor.toLowerCase() == + choice.backgroundColor.toLowerCase(), + ); + if (!supportedChoice) { + throw ArgumentError.value( + choice.providerValue, + 'choice', + 'Unsupported calendar color.', + ); + } + + final now = _now(); + final nowUtc = now.toUtc().toIso8601String(); + final createOp = await _pendingCalendarCreate(source.id); + final foregroundColor = calendarColorForegroundHex(choice.backgroundColor); + final request = switch (entity.provider) { + BusyProvider.google => { + 'backgroundColor': choice.backgroundColor, + 'foregroundColor': foregroundColor, + calendarMutationScopeKey: calendarMutationScopePersonal, + }, + BusyProvider.microsoft => { + 'colorId': choice.providerValue, + }, + BusyProvider.appleICloud || BusyProvider.nextcloud => throw StateError( + 'DAV calendar colors are not mutable.', + ), + BusyProvider.webCal => throw StateError( + 'WebCal subscription colors use local subscription operations.', + ), + }; + await _database.transaction(() async { + await (_database.update( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).write( + CalendarSourcesCompanion( + backgroundColor: Value(choice.backgroundColor), + foregroundColor: entity.provider == BusyProvider.google + ? Value(foregroundColor) + : const Value.absent(), + colorId: Value( + entity.provider == BusyProvider.microsoft + ? choice.providerValue + : null, + ), + updatedAtLocal: Value(now.millisecondsSinceEpoch), ), ); + await _enqueueOrMergeCalendarPatch( + source, + request: request, + nowUtc: nowUtc, + dependsOnOpId: createOp?.id, + ); }); } @@ -284,20 +621,47 @@ class CalendarRepository { final source = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).getSingle(); - _requireWritableSource( + final entity = await _sourceEntity(source); + final removalMode = entity.capabilities.removalMode; + final createOp = await _pendingCalendarCreate(source.id); + _requireCalendarSourceCapability( source, - operation: CalendarMutationOperation.deleteCalendar, + operation: removalMode == CalendarRemovalMode.removeFromList + ? CalendarMutationOperation.removeCalendar + : CalendarMutationOperation.deleteCalendar, + allowed: createOp != null || entity.capabilities.canRemoveCalendar, ); - final now = _now().millisecondsSinceEpoch; - final nowUtc = DateTime.now().toUtc().toIso8601String(); + final now = _now(); + final nowUtc = now.toUtc().toIso8601String(); await _database.transaction(() async { + if (createOp != null) { + await (_database.delete(_database.pendingOps)..where( + (row) => + row.accountId.equals(source.accountId) & + row.calendarSourceId.equals(source.id), + )) + .go(); + await (_database.delete( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).go(); + return; + } + if ((await _pendingCalendarWork(source.id)).isNotEmpty) { + throw CalendarMutationNotAllowed( + operation: removalMode == CalendarRemovalMode.removeFromList + ? CalendarMutationOperation.removeCalendar + : CalendarMutationOperation.deleteCalendar, + sourceId: source.id, + reason: CalendarMutationDenialReason.pendingChanges, + ); + } await (_database.update( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).write( CalendarSourcesCompanion( isDeleted: const Value(true), hidden: const Value(true), - updatedAtLocal: Value(now), + updatedAtLocal: Value(now.millisecondsSinceEpoch), ), ); await _database.pendingOpsDao.enqueue( @@ -306,11 +670,19 @@ class CalendarRepository { accountId: source.accountId, provider: Value(source.provider), entityType: 'calendar', - operation: 'delete', - operationType: const Value('calendar.delete'), + operation: removalMode == CalendarRemovalMode.removeFromList + ? 'remove' + : 'delete', + operationType: Value( + removalMode == CalendarRemovalMode.removeFromList + ? 'calendar.remove' + : 'calendar.delete', + ), calendarSourceId: Value(source.id), providerCalendarId: Value(source.providerCalendarId), - requestJson: '{}', + requestJson: jsonEncode({ + calendarRemovalPreviousHiddenKey: source.hidden, + }), baselineRawJson: Value(source.rawJson), createdAtUtc: nowUtc, updatedAtUtc: nowUtc, @@ -337,7 +709,16 @@ class CalendarRepository { final existing = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(id))).getSingleOrNull(); - final isDeleted = (existing?.isDeleted ?? false) || source.isDeleted; + final pendingPatchFields = existing == null + ? const {} + : await _pendingCalendarPatchFields(existing.id); + final preservePendingColor = + pendingPatchFields.contains('backgroundColor') || + pendingPatchFields.contains('foregroundColor') || + pendingPatchFields.contains('colorId'); + final preservePendingRemoval = + existing != null && await _hasActiveCalendarRemoval(existing.id); + final isDeleted = preservePendingRemoval || source.isDeleted; await _database .into(_database.calendarSources) .insertOnConflictUpdate( @@ -346,18 +727,40 @@ class CalendarRepository { accountId: accountId, provider: source.provider.storageValue, providerCalendarId: source.providerCalendarId, - summary: source.summary, - description: Value(source.description), + summary: pendingPatchFields.contains('summary') + ? existing!.summary + : source.summary, + description: Value( + pendingPatchFields.contains('description') + ? existing!.description + : source.description, + ), primaryCalendar: Value(source.primaryCalendar), selected: Value(existing?.selected ?? source.selected), remindersEnabled: Value(existing?.remindersEnabled ?? true), hidden: Value(isDeleted || source.hidden), readOnly: Value(source.readOnly), - backgroundColor: Value(source.backgroundColor), - foregroundColor: Value(source.foregroundColor), - colorId: Value(source.colorId), - timeZone: Value(source.timeZone), + backgroundColor: Value( + preservePendingColor + ? existing!.backgroundColor + : source.backgroundColor, + ), + foregroundColor: Value( + preservePendingColor + ? existing!.foregroundColor + : source.foregroundColor, + ), + colorId: Value( + preservePendingColor ? existing!.colorId : source.colorId, + ), + timeZone: Value( + pendingPatchFields.contains('timeZone') + ? existing!.timeZone + : source.timeZone, + ), accessRole: Value(source.accessRole), + dataOwner: Value(source.dataOwner), + isRemovable: Value(source.isRemovable), isDeleted: Value(isDeleted), rawJson: Value(jsonEncode(source.rawJson)), createdAtLocal: existing?.createdAtLocal ?? now, @@ -367,112 +770,609 @@ class CalendarRepository { }); } - /// Stores a provider event. + /// Reconciles a provider's complete calendar-list snapshot. /// - /// Pull syncs must preserve local optimistic state while an event is dirty or - /// has an outstanding mutation. Mutation acknowledgements remain - /// authoritative so they can transition the row back to `synced`. - Future upsertEvent({ + /// A missing provider calendar is tombstoned locally together with its + /// synchronized event cache. Calendar creations that are still queued are + /// excluded because they cannot be expected to appear in the provider + /// snapshot yet. + Future reconcileProviderSourceSnapshot({ required String accountId, - required CalendarEventDto event, - bool preservePendingLocalChanges = false, + required BusyProvider provider, + required Set activeProviderCalendarIds, }) async { final now = _now().millisecondsSinceEpoch; - final calendarSourceId = sourceId( - accountId: accountId, - provider: event.provider, - providerCalendarId: event.providerCalendarId, - ); - final id = eventId( - accountId: accountId, - provider: event.provider, - providerCalendarId: event.providerCalendarId, - providerEventId: event.providerEventId, - providerOriginalStartKey: event.providerOriginalStartKey, - ); - final eventRow = CalendarEventsCompanion.insert( - id: id, - accountId: accountId, - calendarSourceId: calendarSourceId, - provider: event.provider.storageValue, - providerCalendarId: event.providerCalendarId, - providerEventId: event.providerEventId, - providerRecurringEventId: Value(event.providerRecurringEventId), - providerOriginalStartKey: Value(event.providerOriginalStartKey), - etagOrChangeKey: Value(event.etagOrChangeKey), - status: Value(event.status), - title: event.title, - description: Value(event.description), - location: Value(event.location), - allDay: Value(event.allDay), - startDate: Value(event.startDate), - startDateTime: Value(event.startDateTime), - startTimeZone: Value(event.startTimeZone), - endDate: Value(event.endDate), - endDateTime: Value(event.endDateTime), - endTimeZone: Value(event.endTimeZone), - recurrenceJson: Value(_json(event.recurrenceJson)), - remindersJson: Value(_json(event.remindersJson)), - attendeesJson: Value(_json(event.attendeesJson)), - categoriesJson: Value(_json(event.categoriesJson)), - organizerJson: Value(_json(event.organizerJson)), - creatorJson: Value(_json(event.creatorJson)), - colorId: Value(event.colorId), - colorHex: Value(event.colorHex), - visibility: Value(event.visibility), - transparencyOrShowAs: Value(event.transparencyOrShowAs), - eventType: Value(event.eventType), - webLink: Value(event.webLink), - conferenceJson: Value(_json(event.conferenceJson)), - attachmentsJson: Value(_json(event.attachmentsJson)), - isCancelled: Value(event.isCancelled), - isDeleted: Value(event.isDeleted), - rawJson: Value(jsonEncode(event.rawJson)), - createdAtServer: Value(event.createdAtServer), - updatedAtServer: Value(event.updatedAtServer), - createdAtLocal: now, - updatedAtLocal: now, - syncStatus: const Value('synced'), - baselineRawJson: Value(jsonEncode(event.rawJson)), - ); - if (!preservePendingLocalChanges) { - await _database - .into(_database.calendarEvents) - .insertOnConflictUpdate(eventRow); - return; - } - await _database.transaction(() async { - final localEvent = await (_database.select( - _database.calendarEvents, - )..where((row) => row.id.equals(id))).getSingleOrNull(); - if (localEvent != null) { - if (localEvent.syncStatus != 'synced') { - return; - } - final pendingMutation = - await (_database.select(_database.pendingOps) - ..where( - (row) => - row.accountId.equals(accountId) & - row.entityType.equals('event') & - row.eventId.equals(id), - ) - ..limit(1)) - .getSingleOrNull(); - if (pendingMutation != null) { - return; - } + final pendingCreates = + await (_database.select(_database.pendingOps)..where( + (row) => + row.accountId.equals(accountId) & + row.operationType.equals('calendar.create'), + )) + .get(); + final pendingCreateSourceIds = { + for (final operation in pendingCreates) + if (operation.calendarSourceId != null) operation.calendarSourceId!, + }; + final pendingCreateProviderIds = { + for (final operation in pendingCreates) + if (operation.providerCalendarId != null) + operation.providerCalendarId!, + }; + final sources = + await (_database.select(_database.calendarSources)..where( + (row) => + row.accountId.equals(accountId) & + row.provider.equals(provider.storageValue), + )) + .get(); + final missingSourceIds = { + for (final source in sources) + if (!activeProviderCalendarIds.contains(source.providerCalendarId) && + !pendingCreateSourceIds.contains(source.id) && + !pendingCreateProviderIds.contains(source.providerCalendarId)) + source.id, + }; + if (missingSourceIds.isEmpty) return; + + final missingEventIds = { + for (final event + in await (_database.select(_database.calendarEvents)..where( + (row) => + row.accountId.equals(accountId) & + row.calendarSourceId.isIn(missingSourceIds), + )) + .get()) + event.id, + }; + + await (_database.update(_database.calendarSources)..where( + (row) => + row.accountId.equals(accountId) & row.id.isIn(missingSourceIds), + )) + .write( + CalendarSourcesCompanion( + isDeleted: const Value(true), + hidden: const Value(true), + updatedAtLocal: Value(now), + ), + ); + await (_database.update(_database.calendarEvents)..where( + (row) => + row.accountId.equals(accountId) & + row.calendarSourceId.isIn(missingSourceIds) & + row.syncStatus.equals('synced') & + row.isDeleted.equals(false), + )) + .write( + CalendarEventsCompanion( + isDeleted: const Value(true), + updatedAtLocal: Value(now), + ), + ); + if (missingEventIds.isNotEmpty) { + await (_database.delete(_database.notificationSchedule)..where( + (row) => + row.accountId.equals(accountId) & + row.sourceType.equals('event') & + row.sourceId.isIn(missingEventIds), + )) + .go(); } - await _database - .into(_database.calendarEvents) - .insertOnConflictUpdate(eventRow); + await (_database.delete(_database.syncCursors)..where( + (row) => + row.accountId.equals(accountId) & + row.provider.equals(provider.storageValue) & + row.projectionSourceId.isIn(missingSourceIds), + )) + .go(); }); } - Future saveSyncState({ - required String accountId, - required BusyProvider provider, + Future restoreSourceAfterRemovalFailure(PendingOp op) async { + final operationType = + op.operationType ?? '${op.entityType}.${op.operation}'; + if (operationType != 'calendar.delete' && + operationType != 'calendar.remove') { + return; + } + final sourceId = op.calendarSourceId; + if (sourceId == null) return; + final source = await (_database.select( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).getSingleOrNull(); + if (source == null || !source.isDeleted) return; + await (_database.update( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).write( + CalendarSourcesCompanion( + isDeleted: const Value(false), + hidden: Value(_calendarRemovalPreviousHidden(op)), + updatedAtLocal: Value(_now().millisecondsSinceEpoch), + ), + ); + } + + Future discardPendingCalendarCreation(PendingOp op) async { + if (_calendarOperationType(op) != 'calendar.create') return; + final sourceId = op.calendarSourceId; + if (sourceId == null) return; + await _database.transaction(() async { + final accountOperations = await (_database.select( + _database.pendingOps, + )..where((row) => row.accountId.equals(op.accountId))).get(); + final discardedIds = {op.id}; + var changed = true; + while (changed) { + changed = false; + for (final operation in accountOperations) { + if (discardedIds.contains(operation.id) || + (operation.calendarSourceId != sourceId && + !discardedIds.contains(operation.dependsOnOpId))) { + continue; + } + discardedIds.add(operation.id); + changed = true; + } + } + for (final operationId in discardedIds) { + await _database.pendingOpsDao.deleteOp(operationId); + } + await (_database.delete( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).go(); + }); + await _notificationScheduleService().rebuildUpcomingEventNotifications( + op.accountId, + ); + await _onNotificationScheduleChanged?.call(); + } + + Future discardPendingEventCreation(PendingOp op) async { + if (_calendarOperationType(op) != 'event.create') return; + final eventId = op.eventId; + if (eventId == null) return; + final accountOperations = await (_database.select( + _database.pendingOps, + )..where((row) => row.accountId.equals(op.accountId))).get(); + final discardedIds = {op.id}; + var changed = true; + while (changed) { + changed = false; + for (final operation in accountOperations) { + if (discardedIds.contains(operation.id) || + (operation.eventId != eventId && + !discardedIds.contains(operation.dependsOnOpId))) { + continue; + } + discardedIds.add(operation.id); + changed = true; + } + } + final createdEventIds = { + for (final operation in accountOperations) + if (discardedIds.contains(operation.id) && + _calendarOperationType(operation) == 'event.create' && + operation.eventId != null) + operation.eventId!, + }; + await _database.transaction(() async { + for (final operation in accountOperations) { + if (!discardedIds.contains(operation.id) || + operation.entityType != 'event' || + operation.eventId == null || + createdEventIds.contains(operation.eventId)) { + continue; + } + await _restoreEventProjectionAfterDiscard( + operation, + ignoredOperationIds: discardedIds, + ); + } + await (_database.delete( + _database.pendingOps, + )..where((row) => row.id.isIn(discardedIds))).go(); + await (_database.delete(_database.calendarEvents)..where( + (row) => + row.accountId.equals(op.accountId) & + row.id.isIn(createdEventIds), + )) + .go(); + }); + await _notificationScheduleService().rebuildUpcomingEventNotifications( + op.accountId, + ); + await _onNotificationScheduleChanged?.call(); + } + + Future restoreEventAfterMutationDiscard( + PendingOp op, { + CalendarEventRecoveryFetcher? fetchProviderEvent, + }) async { + if (op.entityType != 'event' || + _calendarOperationType(op) == 'event.create') { + return; + } + final eventId = op.eventId; + if (eventId == null) { + throw StateError('The event recovery identity is unavailable.'); + } + final target = await (_database.select( + _database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingleOrNull(); + if (target == null) return; + final provider = BusyProviderCodec.requireStorageValue(target.provider); + CalendarEventDto? providerSnapshot; + final operationBaseline = _jsonMap(op.baselineRawJson); + final recoveryBaseline = operationBaseline.isNotEmpty + ? operationBaseline + : _jsonMap(target.baselineRawJson); + if (!_isCompleteProviderEventBaseline(provider, recoveryBaseline)) { + if (fetchProviderEvent == null) { + throw StateError('The provider event recovery client is unavailable.'); + } + providerSnapshot = await fetchProviderEvent( + calendarId: target.providerCalendarId, + eventId: target.providerEventId, + ); + } + await _database.transaction( + () => _restoreEventProjectionAfterDiscard( + op, + ignoredOperationIds: {op.id}, + providerSnapshot: providerSnapshot, + ), + ); + await _notificationScheduleService().rebuildUpcomingEventNotifications( + op.accountId, + ); + await _onNotificationScheduleChanged?.call(); + } + + Future _restoreEventProjectionAfterDiscard( + PendingOp op, { + required Set ignoredOperationIds, + CalendarEventDto? providerSnapshot, + }) async { + final eventId = op.eventId; + if (eventId == null) return; + final target = await (_database.select( + _database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingleOrNull(); + if (target == null) return; + final request = _jsonMap(op.requestJson); + final seriesProviderEventId = request[calendarEventTargetProviderIdKey] + ?.toString(); + final affectedEvents = seriesProviderEventId == null + ? [target] + : await (_database.select(_database.calendarEvents)..where( + (row) => + row.accountId.equals(target.accountId) & + row.provider.equals(target.provider) & + row.providerCalendarId.equals(target.providerCalendarId) & + (row.id.equals(target.id) | + row.providerEventId.equals(seriesProviderEventId) | + row.providerRecurringEventId.equals( + seriesProviderEventId, + )), + )) + .get(); + final affectedIds = {for (final event in affectedEvents) event.id}; + final remainingOperations = + await (_database.select(_database.pendingOps)..where( + (row) => + row.accountId.equals(op.accountId) & + row.entityType.equals('event') & + row.id.isNotIn(ignoredOperationIds) & + row.eventId.isIn(affectedIds), + )) + .get(); + if (remainingOperations.isNotEmpty) return; + + for (final event in affectedEvents) { + final provider = BusyProviderCodec.requireStorageValue(event.provider); + final operationBaseline = event.id == eventId + ? _jsonMap(op.baselineRawJson) + : const {}; + final restored = event.id == eventId && providerSnapshot != null + ? providerSnapshot + : _eventFromRecoveryBaseline( + event, + provider, + operationBaseline.isNotEmpty + ? operationBaseline + : _jsonMap(event.baselineRawJson), + ); + final restoredId = CalendarRepository.eventId( + accountId: event.accountId, + provider: provider, + providerCalendarId: restored.providerCalendarId, + providerEventId: restored.providerEventId, + providerOriginalStartKey: restored.providerOriginalStartKey, + ); + if (restoredId != event.id) { + throw StateError('The event recovery baseline identity changed.'); + } + await upsertEvent(accountId: event.accountId, event: restored); + } + } + + CalendarEventDto _eventFromRecoveryBaseline( + CalendarEvent event, + BusyProvider provider, + Map baseline, + ) { + if (!_isCompleteProviderEventBaseline(provider, baseline)) { + throw StateError('A complete event recovery baseline is unavailable.'); + } + baseline.putIfAbsent('id', () => event.providerEventId); + switch (provider) { + case BusyProvider.google: + if (event.providerRecurringEventId case final recurringId?) { + baseline.putIfAbsent('recurringEventId', () => recurringId); + } + if (event.providerOriginalStartKey case final originalStart?) { + baseline.putIfAbsent( + 'originalStartTime', + () => originalStart.length == 10 + ? {'date': originalStart} + : {'dateTime': originalStart}, + ); + } + case BusyProvider.microsoft: + if (event.providerRecurringEventId case final recurringId?) { + baseline.putIfAbsent('seriesMasterId', () => recurringId); + } + if (event.providerOriginalStartKey case final originalStart?) { + baseline.putIfAbsent('originalStart', () => originalStart); + } + case BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal: + break; + } + return switch (provider) { + BusyProvider.google => googleCalendarEventFromJson( + event.providerCalendarId, + baseline, + ), + BusyProvider.microsoft => microsoftCalendarEventFromJson( + event.providerCalendarId, + baseline, + ), + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => throw StateError( + 'Provider event recovery is unavailable for ${provider.storageValue}.', + ), + }; + } + + bool _isCompleteProviderEventBaseline( + BusyProvider provider, + Map baseline, + ) { + if (baseline.isEmpty || + baseline.containsKey(calendarEventSemanticBaselineKey)) { + return false; + } + final start = _jsonObjectMap(baseline['start']); + final end = _jsonObjectMap(baseline['end']); + return switch (provider) { + BusyProvider.google => + _hasNonEmptyValue(start, 'date', 'dateTime') && + _hasNonEmptyValue(end, 'date', 'dateTime'), + BusyProvider.microsoft => + _hasNonEmptyValue(start, 'dateTime') && + _hasNonEmptyValue(end, 'dateTime'), + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => false, + }; + } + + bool _hasNonEmptyValue( + Map values, + String key, [ + String? alternativeKey, + ]) { + final value = values[key]?.toString().trim(); + if (value != null && value.isNotEmpty) return true; + final alternative = alternativeKey == null + ? null + : values[alternativeKey]?.toString().trim(); + return alternative != null && alternative.isNotEmpty; + } + + Future restoreSourceAfterPatchDiscard(PendingOp op) async { + if (_calendarOperationType(op) != 'calendar.patch') return; + final sourceId = op.calendarSourceId; + if (sourceId == null) return; + final source = await (_database.select( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).getSingleOrNull(); + if (source == null) return; + final request = _jsonMap(op.requestJson); + final previous = _jsonObjectMap(request[calendarPatchPreviousValuesKey]); + final baseline = _jsonMap(op.baselineRawJson); + final changesSummary = request.containsKey('summary'); + final changesColor = + request.containsKey('backgroundColor') || + request.containsKey('foregroundColor') || + request.containsKey('colorId'); + if (!changesSummary && !changesColor) return; + final provider = BusyProviderCodec.requireStorageValue(source.provider); + final baselineSummary = switch (provider) { + BusyProvider.google => + baseline['summaryOverride']?.toString().trim().isNotEmpty == true + ? baseline['summaryOverride']!.toString() + : baseline['summary']?.toString(), + BusyProvider.microsoft => baseline['name']?.toString(), + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, + }; + final baselineColorId = switch (provider) { + BusyProvider.google => baseline['colorId']?.toString(), + BusyProvider.microsoft => baseline['color']?.toString(), + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, + }; + final baselineBackground = switch (provider) { + BusyProvider.google => baseline['backgroundColor']?.toString(), + BusyProvider.microsoft => calendarSourceBackgroundColorHex( + provider: provider, + backgroundColor: baseline['hexColor']?.toString(), + colorId: baselineColorId, + ), + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, + }; + await (_database.update( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).write( + CalendarSourcesCompanion( + summary: changesSummary + ? Value( + previous['summary']?.toString() ?? + baselineSummary ?? + source.summary, + ) + : const Value.absent(), + backgroundColor: changesColor + ? Value( + previous.containsKey('backgroundColor') + ? previous['backgroundColor']?.toString() + : baselineBackground, + ) + : const Value.absent(), + foregroundColor: changesColor + ? Value( + previous.containsKey('foregroundColor') + ? previous['foregroundColor']?.toString() + : baseline['foregroundColor']?.toString(), + ) + : const Value.absent(), + colorId: changesColor + ? Value( + previous.containsKey('colorId') + ? previous['colorId']?.toString() + : baselineColorId, + ) + : const Value.absent(), + updatedAtLocal: Value(_now().millisecondsSinceEpoch), + ), + ); + } + + /// Stores a provider event. + /// + /// Pull syncs must preserve local optimistic state while an event is dirty or + /// has an outstanding mutation. Mutation acknowledgements remain + /// authoritative so they can transition the row back to `synced`. + Future upsertEvent({ + required String accountId, + required CalendarEventDto event, + bool preservePendingLocalChanges = false, + }) async { + final now = _now().millisecondsSinceEpoch; + final calendarSourceId = sourceId( + accountId: accountId, + provider: event.provider, + providerCalendarId: event.providerCalendarId, + ); + final id = eventId( + accountId: accountId, + provider: event.provider, + providerCalendarId: event.providerCalendarId, + providerEventId: event.providerEventId, + providerOriginalStartKey: event.providerOriginalStartKey, + ); + final eventRow = CalendarEventsCompanion.insert( + id: id, + accountId: accountId, + calendarSourceId: calendarSourceId, + provider: event.provider.storageValue, + providerCalendarId: event.providerCalendarId, + providerEventId: event.providerEventId, + providerRecurringEventId: Value(event.providerRecurringEventId), + providerOriginalStartKey: Value(event.providerOriginalStartKey), + etagOrChangeKey: Value(event.etagOrChangeKey), + status: Value(event.status), + title: event.title, + description: Value(event.description), + location: Value(event.location), + allDay: Value(event.allDay), + startDate: Value(event.startDate), + startDateTime: Value(event.startDateTime), + startTimeZone: Value(event.startTimeZone), + endDate: Value(event.endDate), + endDateTime: Value(event.endDateTime), + endTimeZone: Value(event.endTimeZone), + recurrenceJson: Value(_json(event.recurrenceJson)), + remindersJson: Value(_json(event.remindersJson)), + attendeesJson: Value(_json(event.attendeesJson)), + categoriesJson: Value(_json(event.categoriesJson)), + organizerJson: Value(_json(event.organizerJson)), + creatorJson: Value(_json(event.creatorJson)), + colorId: Value(event.colorId), + colorHex: Value(event.colorHex), + visibility: Value(event.visibility), + transparencyOrShowAs: Value(event.transparencyOrShowAs), + eventType: Value(event.eventType), + webLink: Value(event.webLink), + conferenceJson: Value(_json(event.conferenceJson)), + attachmentsJson: Value(_json(event.attachmentsJson)), + isCancelled: Value(event.isCancelled), + isDeleted: Value(event.isDeleted), + rawJson: Value(jsonEncode(event.rawJson)), + createdAtServer: Value(event.createdAtServer), + updatedAtServer: Value(event.updatedAtServer), + createdAtLocal: now, + updatedAtLocal: now, + syncStatus: const Value('synced'), + baselineRawJson: Value(jsonEncode(event.rawJson)), + ); + if (!preservePendingLocalChanges) { + await _database + .into(_database.calendarEvents) + .insertOnConflictUpdate(eventRow); + return; + } + + await _database.transaction(() async { + final localEvent = await (_database.select( + _database.calendarEvents, + )..where((row) => row.id.equals(id))).getSingleOrNull(); + if (localEvent != null) { + if (localEvent.syncStatus != 'synced') { + return; + } + final pendingMutation = + await (_database.select(_database.pendingOps) + ..where( + (row) => + row.accountId.equals(accountId) & + row.entityType.equals('event') & + row.eventId.equals(id), + ) + ..limit(1)) + .getSingleOrNull(); + if (pendingMutation != null) { + return; + } + } + await _database + .into(_database.calendarEvents) + .insertOnConflictUpdate(eventRow); + }); + } + + Future saveSyncState({ + required String accountId, + required BusyProvider provider, required String syncKind, String? calendarSourceId, String? rangeStart, @@ -528,10 +1428,11 @@ class CalendarRepository { }); } - Future createLocalEvent( + Future createLocalEvent( EventEditorDraft draft, { CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, + bool rebuildNotifications = true, }) async { final source = await (_database.select( _database.calendarSources, @@ -548,12 +1449,18 @@ class CalendarRepository { ); } if (source.davCollectionId != null) { - return _createLocalDavEvent(source, draft); + return _createLocalDavEvent( + source, + draft, + rebuildNotifications: rebuildNotifications, + ); } + final calendarCreateOp = await _pendingCalendarCreate(source.id); final now = _now().millisecondsSinceEpoch; final provider = BusyProviderCodec.requireStorageValue(source.provider); final conferenceRequest = _conferenceRequest(draft, provider); final localEventId = 'local:${const Uuid().v4()}'; + final operationId = const Uuid().v4(); final startTimeZone = _effectiveStartTimeZone( draft, source.timeZone, @@ -571,17 +1478,29 @@ class CalendarRepository { providerCalendarId: draft.providerCalendarId, providerEventId: localEventId, ); - final requestJson = jsonEncode( - _eventRequest( - draft, - provider, - isCreate: true, - startTimeZone: startTimeZone, - endTimeZone: endTimeZone, - conference: conferenceRequest, - guestUpdatePolicy: guestUpdatePolicy, - ), + final request = _eventRequest( + draft, + provider, + isCreate: true, + startTimeZone: startTimeZone, + endTimeZone: endTimeZone, + conference: conferenceRequest, + guestUpdatePolicy: guestUpdatePolicy, ); + switch (provider) { + case BusyProvider.google: + request[calendarEventGoogleCreateIdKey] = googleCalendarCreateEventId( + operationId, + ); + case BusyProvider.microsoft: + request[calendarEventMicrosoftTransactionIdKey] = + microsoftCalendarCreateTransactionId(operationId); + case BusyProvider.appleICloud: + case BusyProvider.nextcloud: + case BusyProvider.webCal: + break; + } + final requestJson = jsonEncode(request); await _database.transaction(() async { await _database .into(_database.calendarEvents) @@ -631,7 +1550,7 @@ class CalendarRepository { .into(_database.pendingOps) .insert( PendingOpsCompanion.insert( - id: const Uuid().v4(), + id: operationId, accountId: draft.accountId, provider: Value(source.provider), entityType: 'event', @@ -641,16 +1560,64 @@ class CalendarRepository { providerCalendarId: Value(draft.providerCalendarId), eventId: Value(id), localTempId: Value(localEventId), + dependsOnOpId: Value(calendarCreateOp?.id), requestJson: requestJson, createdAtUtc: DateTime.now().toUtc().toIso8601String(), updatedAtUtc: DateTime.now().toUtc().toIso8601String(), ), ); }); + if (rebuildNotifications) { + await _notificationScheduleService().rebuildUpcomingEventNotifications( + draft.accountId, + ); + await _onNotificationScheduleChanged?.call(); + } + return operationId; + } + + Future> createImportedEventsBatch({ + required CalendarSourceEntity destination, + required List<({String icalUid, EventEditorDraft draft})> events, + }) async { + if (events.isEmpty) return const []; + if (events.any( + (event) => + event.draft.accountId != destination.accountId || + event.draft.sourceId != destination.id || + event.draft.providerCalendarId != destination.providerCalendarId || + event.draft.attendees.isNotEmpty || + event.draft.createConference || + event.draft.conference != null, + )) { + throw ArgumentError('The import batch contains an unsafe event draft.'); + } + final operationIds = []; + await _database.transaction(() async { + for (final event in events) { + final operationId = await createLocalEvent( + event.draft, + guestUpdatePolicy: CalendarGuestUpdatePolicy.doNotSend, + rebuildNotifications: false, + ); + operationIds.add(operationId); + await _database + .into(_database.icalImportReceipts) + .insert( + IcalImportReceiptsCompanion.insert( + calendarSourceId: destination.id, + icalUid: event.icalUid, + eventId: const Value(null), + importedAtUtc: _now().toUtc().toIso8601String(), + ), + ); + } + }); await _notificationScheduleService().rebuildUpcomingEventNotifications( - draft.accountId, + destination.accountId, ); await _onNotificationScheduleChanged?.call(); + return operationIds; } Future updateLocalEvent( @@ -660,7 +1627,8 @@ class CalendarRepository { }) async { final eventId = draft.eventId; if (eventId == null) { - return createLocalEvent(draft, guestUpdatePolicy: guestUpdatePolicy); + await createLocalEvent(draft, guestUpdatePolicy: guestUpdatePolicy); + return; } final source = await (_database.select( _database.calendarSources, @@ -668,34 +1636,78 @@ class CalendarRepository { final existing = await (_database.select( _database.calendarEvents, )..where((row) => row.id.equals(eventId))).getSingle(); + final originalSource = await (_database.select( + _database.calendarSources, + )..where((row) => row.id.equals(existing.calendarSourceId))).getSingle(); + _requireFullEventEditingAllowed(existing); + _requireAttendeeManagementAllowed(existing, draft); + final editBaseline = _eventEditBaseline(draft, existing); _requireWritableSource( source, operation: CalendarMutationOperation.editEvent, ); + if (source.accountId != draft.accountId || + source.providerCalendarId != draft.providerCalendarId) { + throw CalendarMutationNotAllowed( + operation: CalendarMutationOperation.editEvent, + sourceId: source.id, + ); + } final sourceChanged = draft.accountId != existing.accountId || draft.sourceId != existing.calendarSourceId || - draft.providerCalendarId != existing.providerCalendarId || - source.accountId != existing.accountId || - source.provider != existing.provider || - source.providerCalendarId != existing.providerCalendarId; + draft.providerCalendarId != existing.providerCalendarId; if (sourceChanged) { - throw UnsupportedError( - 'Moving an existing event to another calendar or account is not ' - 'supported.', + _requireWritableSource( + originalSource, + operation: CalendarMutationOperation.deleteEvent, + ); + return _moveLocalEvent( + originalSource: originalSource, + destinationSource: source, + existing: existing, + draft: draft, + guestUpdatePolicy: guestUpdatePolicy, + ); + } + if (source.provider != existing.provider || + source.accountId != existing.accountId || + source.providerCalendarId != existing.providerCalendarId) { + throw CalendarMutationNotAllowed( + operation: CalendarMutationOperation.editEvent, + sourceId: source.id, ); } if (source.davCollectionId != null) { return _updateLocalDavEvent(source, existing, draft); } - if (draft.recurrenceChanged && existing.providerRecurringEventId != null) { + final provider = BusyProviderCodec.requireStorageValue(source.provider); + final recurringOccurrence = existing.providerRecurringEventId != null; + final recurringScope = draft.recurringMutationScope; + if (recurringOccurrence && recurringScope == null) { + throw UnsupportedError('A recurring-event editing scope is required.'); + } + if (recurringScope == RecurringEventMutationScope.thisAndFuture && + provider != BusyProvider.google) { + throw UnsupportedError( + '${provider.displayName} does not expose a documented ' + 'this-and-following mutation through this API.', + ); + } + if (recurringScope == RecurringEventMutationScope.thisAndFuture && + !_googleEventCanSplit(existing)) { + throw UnsupportedError( + 'This Google series contains event-type or conference data that ' + 'cannot be split safely.', + ); + } + if (draft.recurrenceChanged && recurringOccurrence) { throw UnsupportedError( 'Editing a recurring series from an individual occurrence is not ' 'supported.', ); } final now = _now().millisecondsSinceEpoch; - final provider = BusyProviderCodec.requireStorageValue(source.provider); final conferenceRequest = _conferenceRequest(draft, provider); final startTimeZone = _effectiveStartTimeZone( draft, @@ -708,80 +1720,63 @@ class CalendarRepository { startTimeZone, _localTimeZone, ); - final requestJson = jsonEncode( - _eventRequest( - draft, - provider, - isCreate: false, - startTimeZone: startTimeZone, - endTimeZone: endTimeZone, - conference: conferenceRequest, - guestUpdatePolicy: guestUpdatePolicy, - ), + final seriesMutation = + recurringScope == RecurringEventMutationScope.entireSeries || + recurringScope == RecurringEventMutationScope.thisAndFuture; + final request = _eventDeltaRequest( + draft, + editBaseline, + provider, + startTimeZone: startTimeZone, + endTimeZone: endTimeZone, + conference: conferenceRequest, + guestUpdatePolicy: guestUpdatePolicy, ); + if (!_eventRequestHasMutation(request)) { + return; + } + if (recurringOccurrence) { + request[calendarEventRecurringScopeKey] = recurringScope!.name; + if (seriesMutation) { + request[calendarEventTargetProviderIdKey] = + existing.providerRecurringEventId; + request[calendarEventOriginalStartKey] = + existing.providerOriginalStartKey ?? _storedEventStart(existing); + request[calendarEventOriginalEndKey] = _storedEventEnd(existing); + } + } + final requestJson = jsonEncode(request); await _database.transaction(() async { final predecessor = await _latestPendingEventEdit( accountId: draft.accountId, eventId: eventId, ); - await (_database.update( - _database.calendarEvents, - )..where((row) => row.id.equals(eventId))).write( - CalendarEventsCompanion( - accountId: Value(draft.accountId), - calendarSourceId: Value(draft.sourceId), - provider: Value(source.provider), - providerCalendarId: Value(draft.providerCalendarId), - title: Value(draft.title.trim()), - description: Value(draft.description), - location: Value(draft.location), - allDay: Value(draft.allDay), - startDate: Value(draft.allDay ? _date(draft.start) : null), - startDateTime: Value( - draft.allDay ? null : draft.start?.toIso8601String(), - ), - startTimeZone: Value(startTimeZone), - endDate: Value(draft.allDay ? _date(draft.end) : null), - endDateTime: Value( - draft.allDay ? null : draft.end?.toIso8601String(), - ), - endTimeZone: Value(endTimeZone), - recurrenceJson: draft.recurrenceChanged - ? Value(_json(draft.recurrence)) - : const Value.absent(), - remindersJson: Value(_json(draft.reminders)), - attendeesJson: draft.attendeesChanged - ? Value(_json(_localAttendeesJson(draft, provider))) - : const Value.absent(), - categoriesJson: draft.categoriesChanged - ? Value(_json(_categoriesJson(draft, provider))) - : const Value.absent(), - colorId: Value(draft.colorId), - visibility: Value(draft.visibilityOrSensitivity), - transparencyOrShowAs: Value(draft.showAs), - conferenceJson: Value(_json(conferenceRequest ?? draft.conference)), - organizerJson: Value( - _json( - _optimisticOrganizer( - draft, - provider, - existingJson: existing.organizerJson, - ), - ), - ), - rawJson: Value( - jsonEncode( - _optimisticEventRaw( - draft, - provider, - existingJson: existing.rawJson, - ), - ), - ), - updatedAtLocal: Value(now), - syncStatus: const Value('pending'), - ), + final projection = _eventPatchProjection( + draft: draft, + existing: existing, + provider: provider, + request: request, + startTimeZone: startTimeZone, + endTimeZone: endTimeZone, + conference: conferenceRequest, + now: now, ); + if (seriesMutation) { + await _writeCloudSeriesProjection( + existing: existing, + draft: draft, + scope: recurringScope!, + provider: provider, + conference: conferenceRequest, + startTimeZone: startTimeZone, + endTimeZone: endTimeZone, + now: now, + ); + } else { + await (_database.update( + _database.calendarEvents, + )..where((row) => row.id.equals(eventId))).write(projection); + } await _database .into(_database.pendingOps) .insert( @@ -797,8 +1792,12 @@ class CalendarRepository { eventId: Value(eventId), dependsOnOpId: Value(predecessor?.id), requestJson: requestJson, - baselineUpdatedUtc: Value(existing.updatedAtServer), - baselineRawJson: Value(existing.baselineRawJson), + baselineUpdatedUtc: seriesMutation + ? const Value(null) + : Value(editBaseline.updatedAtServer), + baselineRawJson: seriesMutation + ? const Value(null) + : Value(_eventDetailBaselineRawJson(editBaseline)), createdAtUtc: DateTime.now().toUtc().toIso8601String(), updatedAtUtc: DateTime.now().toUtc().toIso8601String(), ), @@ -810,6 +1809,894 @@ class CalendarRepository { await _onNotificationScheduleChanged?.call(); } + Future _moveLocalEvent({ + required CalendarSource originalSource, + required CalendarSource destinationSource, + required CalendarEvent existing, + required EventEditorDraft draft, + required CalendarGuestUpdatePolicy guestUpdatePolicy, + }) async { + if (await _pendingCalendarCreate(destinationSource.id) != null) { + throw CalendarMutationNotAllowed( + operation: CalendarMutationOperation.moveEvent, + sourceId: destinationSource.id, + reason: CalendarMutationDenialReason.destinationPendingCreate, + ); + } + final sourceProvider = BusyProviderCodec.requireStorageValue( + originalSource.provider, + ); + final destinationProvider = BusyProviderCodec.requireStorageValue( + destinationSource.provider, + ); + final recurring = existing.providerRecurringEventId != null; + final scope = draft.recurringMutationScope; + if (recurring && scope == null) { + throw UnsupportedError('A recurring-event movement scope is required.'); + } + if (scope == RecurringEventMutationScope.thisAndFuture) { + throw UnsupportedError( + 'This and following events cannot be moved safely. Move this event or ' + 'the entire series.', + ); + } + await _requireEventReadyToMove(existing); + + final strategy = calendarEventMoveStrategy( + sourceAccountId: originalSource.accountId, + sourceId: originalSource.id, + sourceProviderCalendarId: originalSource.providerCalendarId, + sourceProvider: sourceProvider, + sourceDavCollectionId: originalSource.davCollectionId, + destinationAccountId: destinationSource.accountId, + destinationId: destinationSource.id, + destinationProviderCalendarId: destinationSource.providerCalendarId, + destinationProvider: destinationProvider, + destinationDavCollectionId: destinationSource.davCollectionId, + eventType: existing.eventType, + recurring: recurring, + recurringScope: scope, + ); + switch (strategy) { + case CalendarEventMoveStrategy.none: + throw StateError('The destination calendar is unchanged.'); + case CalendarEventMoveStrategy.googleNative: + await _moveLocalGoogleEvent( + originalSource: originalSource, + destinationSource: destinationSource, + existing: existing, + draft: draft, + guestUpdatePolicy: guestUpdatePolicy, + ); + case CalendarEventMoveStrategy.davNative: + await _moveLocalDavEvent( + originalSource: originalSource, + destinationSource: destinationSource, + existing: existing, + draft: draft, + ); + case CalendarEventMoveStrategy.copyThenDelete: + await _copyThenDeleteLocalEvent( + originalSource: originalSource, + destinationSource: destinationSource, + existing: existing, + draft: draft, + sourceProvider: sourceProvider, + destinationProvider: destinationProvider, + guestUpdatePolicy: guestUpdatePolicy, + ); + } + } + + Future _requireEventReadyToMove(CalendarEvent existing) async { + final pending = + await (_database.select(_database.pendingOps) + ..where( + (row) => + row.entityType.equals('event') & + row.eventId.equals(existing.id), + ) + ..limit(1)) + .getSingleOrNull(); + if (existing.syncStatus != 'synced' || pending != null) { + throw StateError( + 'Wait for this event to finish synchronizing before moving it.', + ); + } + } + + Future _moveLocalGoogleEvent({ + required CalendarSource originalSource, + required CalendarSource destinationSource, + required CalendarEvent existing, + required EventEditorDraft draft, + required CalendarGuestUpdatePolicy guestUpdatePolicy, + }) async { + final recurring = existing.providerRecurringEventId != null; + final scope = draft.recurringMutationScope; + final provider = BusyProvider.google; + final startTimeZone = _effectiveStartTimeZone( + draft, + destinationSource.timeZone, + _localTimeZone, + ); + final endTimeZone = _effectiveEndTimeZone( + draft, + destinationSource.timeZone, + startTimeZone, + _localTimeZone, + ); + final conferenceRequest = _conferenceRequest(draft, provider); + final seriesMutation = + recurring && scope == RecurringEventMutationScope.entireSeries; + final patchRequest = _eventDeltaRequest( + draft, + _eventEditBaseline(draft, existing), + provider, + startTimeZone: startTimeZone, + endTimeZone: endTimeZone, + conference: conferenceRequest, + guestUpdatePolicy: guestUpdatePolicy, + ); + final targetProviderEventId = seriesMutation + ? existing.providerRecurringEventId! + : existing.providerEventId; + if (seriesMutation) { + patchRequest[calendarEventRecurringScopeKey] = scope!.name; + patchRequest[calendarEventTargetProviderIdKey] = targetProviderEventId; + patchRequest[calendarEventOriginalStartKey] = + existing.providerOriginalStartKey ?? _storedEventStart(existing); + patchRequest[calendarEventOriginalEndKey] = _storedEventEnd(existing); + } + final moveOperationId = const Uuid().v4(); + final patchOperationId = const Uuid().v4(); + final nowUtc = DateTime.now().toUtc().toIso8601String(); + final patchCreatedAtUtc = DateTime.parse( + nowUtc, + ).add(const Duration(microseconds: 1)).toIso8601String(); + final now = _now().millisecondsSinceEpoch; + await _database.transaction(() async { + if (seriesMutation && _eventRequestHasMutation(patchRequest)) { + await _writeCloudSeriesProjection( + existing: existing, + draft: draft, + scope: scope!, + provider: provider, + conference: conferenceRequest, + startTimeZone: startTimeZone, + endTimeZone: endTimeZone, + now: now, + ); + } else { + await (_database.update( + _database.calendarEvents, + )..where((row) => row.id.equals(existing.id))).write( + CalendarEventsCompanion( + syncStatus: const Value('pending'), + updatedAtLocal: Value(now), + ), + ); + } + await _database + .into(_database.pendingOps) + .insert( + PendingOpsCompanion.insert( + id: moveOperationId, + accountId: existing.accountId, + provider: Value(existing.provider), + entityType: 'event', + operation: 'move', + operationType: const Value('event.move'), + calendarSourceId: Value(originalSource.id), + providerCalendarId: Value(originalSource.providerCalendarId), + eventId: Value(existing.id), + requestJson: jsonEncode({ + calendarEventDestinationCalendarIdKey: + destinationSource.providerCalendarId, + calendarEventDestinationSourceIdKey: destinationSource.id, + calendarEventTargetProviderIdKey: targetProviderEventId, + calendarEventGuestUpdatePolicyKey: guestUpdatePolicy.name, + if (seriesMutation) + calendarEventRecurringScopeKey: + RecurringEventMutationScope.entireSeries.name, + }), + baselineUpdatedUtc: seriesMutation + ? const Value(null) + : Value(existing.updatedAtServer), + baselineRawJson: seriesMutation + ? const Value(null) + : Value(existing.baselineRawJson), + createdAtUtc: nowUtc, + updatedAtUtc: nowUtc, + ), + ); + if (_eventRequestHasMutation(patchRequest)) { + await _database + .into(_database.pendingOps) + .insert( + PendingOpsCompanion.insert( + id: patchOperationId, + accountId: existing.accountId, + provider: Value(destinationSource.provider), + entityType: 'event', + operation: 'patch', + operationType: const Value('event.patch'), + calendarSourceId: Value(destinationSource.id), + providerCalendarId: Value(destinationSource.providerCalendarId), + eventId: Value(existing.id), + dependsOnOpId: Value(moveOperationId), + requestJson: jsonEncode(patchRequest), + createdAtUtc: patchCreatedAtUtc, + updatedAtUtc: patchCreatedAtUtc, + ), + ); + } + }); + await _rebuildEventNotificationsFor({existing.accountId}); + } + + Future _moveLocalDavEvent({ + required CalendarSource originalSource, + required CalendarSource destinationSource, + required CalendarEvent existing, + required EventEditorDraft draft, + }) async { + _requireDavSchedulingUnchanged(draft, creating: false); + final sourceCollectionId = originalSource.davCollectionId; + final destinationCollectionId = destinationSource.davCollectionId; + final objectId = existing.davObjectId; + final uid = existing.icalUid; + final start = draft.start; + final end = draft.end; + if (sourceCollectionId == null || + destinationCollectionId == null || + objectId == null || + uid == null || + start == null || + end == null) { + throw StateError('The DAV event is not ready to move.'); + } + final queue = DavPendingOperationQueue( + database: _database, + nowUtc: () => _now().toUtc(), + ); + final baselineRawIcs = await queue.editableRawIcsForObject( + accountId: existing.accountId, + collectionId: sourceCollectionId, + objectId: objectId, + ); + final recurring = existing.providerRecurringEventId != null; + final target = IcalComponentKey( + componentType: 'VEVENT', + uid: uid, + recurrenceIdKey: recurring ? null : existing.recurrenceIdKey, + ); + final startTimeZone = _effectiveStartTimeZone( + draft, + destinationSource.timeZone, + _localTimeZone, + ); + final endTimeZone = _effectiveEndTimeZone( + draft, + destinationSource.timeZone, + startTimeZone, + _localTimeZone, + ); + var input = _davEventInput( + draft, + start: start, + end: end, + startTimeZone: startTimeZone, + endTimeZone: endTimeZone, + ); + if (recurring) { + input = _seriesDavEventInput( + baselineRawIcs: baselineRawIcs, + uid: uid, + existing: existing, + desired: input, + ); + } + final patch = buildDavEventUpdatePatch( + target: target, + baselineRawIcs: baselineRawIcs, + input: input, + ); + final candidate = + patch?.applyTo(baselineRawIcs, nowUtc: _now().toUtc()) ?? + baselineRawIcs; + await _database.transaction(() async { + await queue.enqueueMove( + accountId: existing.accountId, + sourceCollectionId: sourceCollectionId, + destinationCollectionId: destinationCollectionId, + objectId: objectId, + target: target, + localProjectionId: existing.id, + postMovePatch: patch, + ); + await DavObjectRepository( + database: _database, + ).projectLocalMutationCandidate( + accountId: existing.accountId, + collectionId: sourceCollectionId, + provider: BusyProviderCodec.requireStorageValue( + originalSource.provider, + ), + objectId: objectId, + candidateRawIcs: candidate, + projectedAtUtc: _now().toUtc(), + ); + await (_database.update(_database.calendarEvents)..where( + (row) => + row.accountId.equals(existing.accountId) & + row.davObjectId.equals(objectId), + )) + .write( + CalendarEventsCompanion( + calendarSourceId: Value(destinationSource.id), + providerCalendarId: Value(destinationSource.providerCalendarId), + davCollectionId: Value(destinationCollectionId), + syncStatus: const Value('pending'), + updatedAtLocal: Value(_now().millisecondsSinceEpoch), + ), + ); + }); + await _rebuildEventNotificationsFor({existing.accountId}); + } + + Future _copyThenDeleteLocalEvent({ + required CalendarSource originalSource, + required CalendarSource destinationSource, + required CalendarEvent existing, + required EventEditorDraft draft, + required BusyProvider sourceProvider, + required BusyProvider destinationProvider, + required CalendarGuestUpdatePolicy guestUpdatePolicy, + }) async { + final copyDraft = _eventCopyDraft( + draft, + existing: existing, + sourceProvider: sourceProvider, + destinationProvider: destinationProvider, + ); + final createOperationId = await createLocalEvent( + copyDraft, + guestUpdatePolicy: guestUpdatePolicy, + ); + try { + if (originalSource.davCollectionId != null) { + await _queueDavEventDeletionAfterCopy( + source: originalSource, + existing: existing, + recurringScope: draft.recurringMutationScope, + dependsOnOperationId: createOperationId, + ); + } else { + await _queueCloudEventDeletionAfterCopy( + existing: existing, + recurringScope: draft.recurringMutationScope, + guestUpdatePolicy: guestUpdatePolicy, + dependsOnOperationId: createOperationId, + ); + } + } on Object { + await _discardQueuedEventCreate(createOperationId); + await _rebuildEventNotificationsFor({destinationSource.accountId}); + rethrow; + } + await (_database.update( + _database.calendarEvents, + )..where((row) => row.id.equals(existing.id))).write( + CalendarEventsCompanion( + syncStatus: const Value('pending'), + updatedAtLocal: Value(_now().millisecondsSinceEpoch), + ), + ); + await _rebuildEventNotificationsFor({ + originalSource.accountId, + destinationSource.accountId, + }); + } + + EventEditorDraft _eventCopyDraft( + EventEditorDraft draft, { + required CalendarEvent existing, + required BusyProvider sourceProvider, + required BusyProvider destinationProvider, + }) { + final recurring = existing.providerRecurringEventId != null; + final scope = draft.recurringMutationScope; + Object? recurrence = draft.recurrence; + if (recurring && scope == RecurringEventMutationScope.singleOccurrence) { + recurrence = null; + } else if (recurrence != null && sourceProvider != destinationProvider) { + final sourceRule = EventRecurrenceCodec.decode( + sourceProvider, + recurrence, + baseDate: draft.start, + ); + if (sourceRule.isSupported && sourceRule.repeats) { + if (!EventRecurrenceCodec.canEncode(destinationProvider, sourceRule)) { + throw UnsupportedError( + '${destinationProvider.displayName} cannot represent this ' + 'recurrence rule.', + ); + } + recurrence = EventRecurrenceCodec.encode( + destinationProvider, + sourceRule, + baseDate: draft.start ?? DateTime.now(), + allDay: draft.allDay, + timeZone: draft.startTimeZone, + ); + } else { + final destinationRule = EventRecurrenceCodec.decode( + destinationProvider, + recurrence, + baseDate: draft.start, + ); + if (!destinationRule.isSupported || !destinationRule.repeats) { + throw UnsupportedError( + '${destinationProvider.displayName} cannot represent this ' + 'recurrence rule.', + ); + } + } + } + if (recurring && scope == RecurringEventMutationScope.entireSeries) { + final rule = EventRecurrenceCodec.decode( + destinationProvider, + recurrence, + baseDate: draft.start, + ); + if (!rule.isSupported || !rule.repeats) { + throw UnsupportedError( + 'The recurrence rule is unavailable. Move one occurrence instead.', + ); + } + } + final attendees = + destinationProvider == BusyProvider.appleICloud || + destinationProvider == BusyProvider.nextcloud + ? const [] + : [ + for (final attendee in draft.attendees) + if (!attendee.self && !attendee.organizer) + EventAttendeeDraft( + email: attendee.email, + displayName: attendee.displayName, + optional: attendee.optional, + ), + ]; + final sameProviderAccount = + sourceProvider == destinationProvider && + existing.accountId == draft.accountId; + return EventEditorDraft( + accountId: draft.accountId, + sourceId: draft.sourceId, + providerCalendarId: draft.providerCalendarId, + title: draft.title, + allDay: draft.allDay, + start: draft.start, + end: draft.end, + startTimeZone: draft.startTimeZone, + endTimeZone: draft.endTimeZone, + location: draft.location, + description: draft.description, + descriptionContentType: destinationProvider == BusyProvider.microsoft + ? draft.descriptionContentType + : null, + descriptionHtml: destinationProvider == BusyProvider.microsoft + ? draft.descriptionHtml + : null, + recurrence: recurrence, + recurrenceChanged: true, + reminders: sourceProvider == destinationProvider + ? draft.reminders + : _eventRemindersForProvider(draft.reminders, destinationProvider), + remindersChanged: true, + attendees: attendees, + attendeesChanged: true, + importance: destinationProvider == BusyProvider.microsoft + ? draft.importance + : null, + showAs: _eventShowAsForProvider(draft.showAs, destinationProvider), + visibilityOrSensitivity: _eventVisibilityForProvider( + draft.visibilityOrSensitivity, + destinationProvider, + ), + colorId: sameProviderAccount ? draft.colorId : null, + categories: destinationProvider == BusyProvider.google + ? const [] + : draft.categories, + categoriesChanged: true, + responseRequested: destinationProvider == BusyProvider.microsoft + ? draft.responseRequested + : null, + hideAttendees: + destinationProvider == BusyProvider.google || + destinationProvider == BusyProvider.microsoft + ? draft.hideAttendees + : null, + allowNewTimeProposals: destinationProvider == BusyProvider.microsoft + ? draft.allowNewTimeProposals + : null, + isOrganizer: true, + ); + } + + Future _queueCloudEventDeletionAfterCopy({ + required CalendarEvent existing, + required RecurringEventMutationScope? recurringScope, + required CalendarGuestUpdatePolicy guestUpdatePolicy, + required String dependsOnOperationId, + }) async { + final series = recurringScope == RecurringEventMutationScope.entireSeries; + final request = { + calendarEventGuestUpdatePolicyKey: guestUpdatePolicy.name, + calendarEventCopyConfirmationRequiredKey: true, + if (recurringScope != null) + calendarEventRecurringScopeKey: recurringScope.name, + if (series) ...{ + calendarEventTargetProviderIdKey: existing.providerRecurringEventId, + calendarEventOriginalStartKey: + existing.providerOriginalStartKey ?? _storedEventStart(existing), + calendarEventOriginalEndKey: _storedEventEnd(existing), + }, + }; + final now = DateTime.now().toUtc().toIso8601String(); + await _database + .into(_database.pendingOps) + .insert( + PendingOpsCompanion.insert( + id: const Uuid().v4(), + accountId: existing.accountId, + provider: Value(existing.provider), + entityType: 'event', + operation: 'delete', + operationType: const Value('event.delete'), + calendarSourceId: Value(existing.calendarSourceId), + providerCalendarId: Value(existing.providerCalendarId), + eventId: Value(existing.id), + dependsOnOpId: Value(dependsOnOperationId), + requestJson: jsonEncode(request), + baselineUpdatedUtc: series + ? const Value(null) + : Value(existing.updatedAtServer), + baselineRawJson: series + ? const Value(null) + : Value(existing.baselineRawJson), + createdAtUtc: now, + updatedAtUtc: now, + ), + ); + } + + Future _queueDavEventDeletionAfterCopy({ + required CalendarSource source, + required CalendarEvent existing, + required RecurringEventMutationScope? recurringScope, + required String dependsOnOperationId, + }) async { + final collectionId = source.davCollectionId; + final objectId = existing.davObjectId; + final uid = existing.icalUid; + if (collectionId == null || objectId == null || uid == null) { + throw StateError('The DAV event baseline is unavailable.'); + } + final queue = DavPendingOperationQueue( + database: _database, + nowUtc: () => _now().toUtc(), + ); + if (recurringScope == RecurringEventMutationScope.singleOccurrence) { + final occurrenceKey = existing.occurrenceKey; + if (occurrenceKey == null) { + throw StateError('The DAV occurrence identity is unavailable.'); + } + final raw = await queue.editableRawIcsForObject( + accountId: existing.accountId, + collectionId: collectionId, + objectId: objectId, + ); + final operationId = await queue.enqueueUpdate( + accountId: existing.accountId, + collectionId: collectionId, + objectId: objectId, + patch: buildDavEventOccurrenceCancellationPatch( + uid: uid, + occurrenceKey: occurrenceKey, + baselineRawIcs: raw, + thisAndFuture: false, + nowUtc: () => _now().toUtc(), + ), + dependsOnOperationId: dependsOnOperationId, + ); + await _requireCopyConfirmation(operationId); + return; + } + final object = await (_database.select( + _database.davObjects, + )..where((row) => row.id.equals(objectId))).getSingle(); + final semantic = IcalSemanticDocument.parse(object.rawIcsBody); + final targets = [ + for (final component in semantic.components) + if (component.componentType == 'VEVENT' && + component.uid == uid && + (recurringScope == RecurringEventMutationScope.entireSeries || + component.recurrenceIdKey == existing.recurrenceIdKey)) + IcalComponentKey( + componentType: component.componentType, + uid: uid, + recurrenceIdKey: component.recurrenceIdKey, + ), + ]; + if (targets.isEmpty) { + throw StateError('The DAV event component is unavailable.'); + } + final identities = targets.map(_icalComponentIdentity).toSet(); + final documents = { + for (final component in semantic.components) + if (identities.contains( + _icalComponentIdentity( + IcalComponentKey( + componentType: component.componentType, + uid: component.uid!, + recurrenceIdKey: component.recurrenceIdKey, + ), + ), + )) + component.documentComponent, + }; + final hasOtherComponent = semantic.document.calendarComponents.any( + (component) => + component.name != 'VTIMEZONE' && !documents.contains(component), + ); + if (!hasOtherComponent) { + final operationId = await queue.enqueueDelete( + accountId: existing.accountId, + collectionId: collectionId, + objectId: objectId, + target: targets.first, + scope: recurringScope == RecurringEventMutationScope.entireSeries + ? DavMutationScope.recurrenceMaster + : DavMutationScope.object, + dependsOnOperationId: dependsOnOperationId, + ); + await _requireCopyConfirmation(operationId); + return; + } + final operationId = await queue.enqueueUpdate( + accountId: existing.accountId, + collectionId: collectionId, + objectId: objectId, + patch: buildDavComponentRemovalPatch( + targets: targets, + scope: recurringScope == RecurringEventMutationScope.entireSeries + ? DavMutationScope.recurrenceMaster + : DavMutationScope.object, + ), + dependsOnOperationId: dependsOnOperationId, + ); + await _requireCopyConfirmation(operationId); + } + + Future _requireCopyConfirmation(String operationId) async { + final operation = await (_database.select( + _database.pendingOps, + )..where((row) => row.id.equals(operationId))).getSingle(); + final request = _jsonMap(operation.requestJson) + ..[calendarEventCopyConfirmationRequiredKey] = true; + await (_database.update(_database.pendingOps) + ..where((row) => row.id.equals(operationId))) + .write(PendingOpsCompanion(requestJson: Value(jsonEncode(request)))); + } + + Future _discardQueuedEventCreate(String operationId) async { + final operation = await (_database.select( + _database.pendingOps, + )..where((row) => row.id.equals(operationId))).getSingleOrNull(); + if (operation == null || operation.attemptCount != 0) return; + await _database.transaction(() async { + await _database.pendingOpsDao.deleteOp(operationId); + final eventId = operation.eventId; + if (eventId != null) { + await (_database.delete( + _database.calendarEvents, + )..where((row) => row.id.equals(eventId))).go(); + } + }); + } + + Future _rebuildEventNotificationsFor(Set accountIds) async { + for (final accountId in accountIds) { + await _notificationScheduleService().rebuildUpcomingEventNotifications( + accountId, + ); + } + await _onNotificationScheduleChanged?.call(); + } + + Future _writeCloudSeriesProjection({ + required CalendarEvent existing, + required EventEditorDraft draft, + required RecurringEventMutationScope scope, + required BusyProvider provider, + required Object? conference, + required String? startTimeZone, + required String? endTimeZone, + required int now, + }) async { + final recurringEventId = existing.providerRecurringEventId; + if (recurringEventId == null) { + throw StateError('The recurring event identifier is unavailable.'); + } + final rows = + await (_database.select(_database.calendarEvents)..where( + (row) => + row.accountId.equals(existing.accountId) & + row.provider.equals(existing.provider) & + row.providerCalendarId.equals(existing.providerCalendarId) & + row.providerRecurringEventId.equals(recurringEventId) & + row.isDeleted.equals(false), + )) + .get(); + final existingStart = _storedEventStartDateTime(existing); + final existingEnd = _storedEventEndDateTime(existing); + final startChanged = + draft.allDay != existing.allDay || draft.start != existingStart; + final endChanged = + draft.allDay != existing.allDay || draft.end != existingEnd; + final startDelta = + startChanged && draft.start != null && existingStart != null + ? draft.start!.difference(existingStart) + : Duration.zero; + final endDelta = endChanged && draft.end != null && existingEnd != null + ? draft.end!.difference(existingEnd) + : Duration.zero; + final raw = _jsonMap(existing.rawJson); + final descriptionChanged = + (draft.description ?? '') != (existing.description ?? ''); + final locationChanged = (draft.location ?? '') != (existing.location ?? ''); + final remindersChanged = !_sameJsonValue( + _decodeStoredJson(existing.remindersJson), + draft.reminders, + ); + final colorChanged = draft.colorId != existing.colorId; + final visibilityChanged = + draft.visibilityOrSensitivity != existing.visibility; + final showAsChanged = draft.showAs != existing.transparencyOrShowAs; + final importanceChanged = draft.importance != raw['importance']?.toString(); + final responseRequestedChanged = + draft.responseRequested != raw['responseRequested']; + final hideAttendeesChanged = provider == BusyProvider.google + ? draft.hideAttendees != + (raw['guestsCanSeeOtherGuests'] is bool + ? !(raw['guestsCanSeeOtherGuests'] as bool) + : null) + : draft.hideAttendees != raw['hideAttendees']; + final newTimeProposalsChanged = + draft.allowNewTimeProposals != raw['allowNewTimeProposals']; + final rawChanged = + importanceChanged || + responseRequestedChanged || + hideAttendeesChanged || + newTimeProposalsChanged; + + for (final row in rows) { + if (!_seriesRowInScope(row, existing, scope)) continue; + final rowStart = _storedEventStartDateTime(row); + final rowEnd = _storedEventEndDateTime(row); + final shiftedStart = startChanged && rowStart != null + ? rowStart.add(startDelta) + : rowStart; + final shiftedEnd = endChanged && rowEnd != null + ? rowEnd.add(endDelta) + : rowEnd; + final companion = CalendarEventsCompanion( + title: draft.title.trim() != existing.title + ? Value(draft.title.trim()) + : const Value.absent(), + description: descriptionChanged + ? Value(draft.description) + : const Value.absent(), + location: locationChanged + ? Value(draft.location) + : const Value.absent(), + allDay: startChanged || endChanged + ? Value(draft.allDay) + : const Value.absent(), + startDate: startChanged + ? Value(draft.allDay ? _date(shiftedStart) : null) + : const Value.absent(), + startDateTime: startChanged + ? Value(draft.allDay ? null : shiftedStart?.toIso8601String()) + : const Value.absent(), + startTimeZone: + startChanged || draft.startTimeZone != existing.startTimeZone + ? Value(draft.allDay ? null : startTimeZone) + : const Value.absent(), + endDate: endChanged + ? Value(draft.allDay ? _date(shiftedEnd) : null) + : const Value.absent(), + endDateTime: endChanged + ? Value(draft.allDay ? null : shiftedEnd?.toIso8601String()) + : const Value.absent(), + endTimeZone: endChanged || draft.endTimeZone != existing.endTimeZone + ? Value(draft.allDay ? null : endTimeZone) + : const Value.absent(), + remindersJson: remindersChanged + ? Value(_json(draft.reminders)) + : const Value.absent(), + attendeesJson: draft.attendeesChanged + ? Value(_json(_localAttendeesJson(draft, provider))) + : const Value.absent(), + categoriesJson: draft.categoriesChanged + ? Value(_json(_categoriesJson(draft, provider))) + : const Value.absent(), + colorId: colorChanged ? Value(draft.colorId) : const Value.absent(), + visibility: visibilityChanged + ? Value(draft.visibilityOrSensitivity) + : const Value.absent(), + transparencyOrShowAs: showAsChanged + ? Value(draft.showAs) + : const Value.absent(), + conferenceJson: conference != null + ? Value(_json(conference)) + : const Value.absent(), + rawJson: rawChanged + ? Value( + jsonEncode( + _optimisticEventRaw( + draft, + provider, + existingJson: row.rawJson, + ), + ), + ) + : const Value.absent(), + updatedAtLocal: Value(now), + syncStatus: const Value('pending'), + ); + await (_database.update( + _database.calendarEvents, + )..where((table) => table.id.equals(row.id))).write(companion); + } + } + + Future _markCloudSeriesDeleted({ + required CalendarEvent existing, + required RecurringEventMutationScope scope, + required int now, + }) async { + final recurringEventId = existing.providerRecurringEventId; + if (recurringEventId == null) { + throw StateError('The recurring event identifier is unavailable.'); + } + final rows = + await (_database.select(_database.calendarEvents)..where( + (row) => + row.accountId.equals(existing.accountId) & + row.provider.equals(existing.provider) & + row.providerCalendarId.equals(existing.providerCalendarId) & + row.providerRecurringEventId.equals(recurringEventId) & + row.isDeleted.equals(false), + )) + .get(); + for (final row in rows) { + if (!_seriesRowInScope(row, existing, scope)) continue; + await (_database.update( + _database.calendarEvents, + )..where((table) => table.id.equals(row.id))).write( + CalendarEventsCompanion( + isDeleted: const Value(true), + syncStatus: const Value('pending'), + updatedAtLocal: Value(now), + ), + ); + } + } + Future _latestPendingEventEdit({ required String accountId, required String eventId, @@ -863,17 +2750,51 @@ class CalendarRepository { recurringScope: recurringScope, ); } + final provider = BusyProviderCodec.requireStorageValue(existing.provider); + final recurringOccurrence = existing.providerRecurringEventId != null; + if (recurringOccurrence && recurringScope == null) { + throw UnsupportedError('A recurring-event deletion scope is required.'); + } + if (recurringScope == RecurringEventMutationScope.thisAndFuture && + provider != BusyProvider.google) { + throw UnsupportedError( + '${provider.displayName} does not expose a documented ' + 'this-and-following mutation through this API.', + ); + } + final seriesMutation = + recurringScope == RecurringEventMutationScope.entireSeries || + recurringScope == RecurringEventMutationScope.thisAndFuture; + final request = { + calendarEventGuestUpdatePolicyKey: guestUpdatePolicy.name, + if (recurringScope != null) + calendarEventRecurringScopeKey: recurringScope.name, + if (seriesMutation) ...{ + calendarEventTargetProviderIdKey: existing.providerRecurringEventId, + calendarEventOriginalStartKey: + existing.providerOriginalStartKey ?? _storedEventStart(existing), + calendarEventOriginalEndKey: _storedEventEnd(existing), + }, + }; final now = _now().millisecondsSinceEpoch; await _database.transaction(() async { - await (_database.update( - _database.calendarEvents, - )..where((row) => row.id.equals(eventId))).write( - CalendarEventsCompanion( - isDeleted: const Value(true), - syncStatus: const Value('pending'), - updatedAtLocal: Value(now), - ), - ); + if (seriesMutation) { + await _markCloudSeriesDeleted( + existing: existing, + scope: recurringScope!, + now: now, + ); + } else { + await (_database.update( + _database.calendarEvents, + )..where((row) => row.id.equals(eventId))).write( + CalendarEventsCompanion( + isDeleted: const Value(true), + syncStatus: const Value('pending'), + updatedAtLocal: Value(now), + ), + ); + } await _database .into(_database.pendingOps) .insert( @@ -887,11 +2808,13 @@ class CalendarRepository { calendarSourceId: Value(existing.calendarSourceId), providerCalendarId: Value(existing.providerCalendarId), eventId: Value(existing.id), - requestJson: jsonEncode({ - calendarEventGuestUpdatePolicyKey: guestUpdatePolicy.name, - }), - baselineUpdatedUtc: Value(existing.updatedAtServer), - baselineRawJson: Value(existing.baselineRawJson), + requestJson: jsonEncode(request), + baselineUpdatedUtc: seriesMutation + ? const Value(null) + : Value(existing.updatedAtServer), + baselineRawJson: seriesMutation + ? const Value(null) + : Value(existing.baselineRawJson), createdAtUtc: DateTime.now().toUtc().toIso8601String(), updatedAtUtc: DateTime.now().toUtc().toIso8601String(), ), @@ -993,10 +2916,11 @@ class CalendarRepository { return existing.accountId; } - Future _createLocalDavEvent( + Future _createLocalDavEvent( CalendarSource source, - EventEditorDraft draft, - ) async { + EventEditorDraft draft, { + required bool rebuildNotifications, + }) async { _requireDavSchedulingUnchanged(draft, creating: true); final start = draft.start; final end = draft.end; @@ -1038,6 +2962,7 @@ class CalendarRepository { 'uid': object.uid, 'localPendingCreate': true, }); + late final String operationId; await _database.transaction(() async { await _database .into(_database.calendarEvents) @@ -1081,20 +3006,24 @@ class CalendarRepository { syncStatus: const Value('pending'), ), ); - await DavPendingOperationQueue( - database: _database, - nowUtc: () => _now().toUtc(), - ).enqueueCreate( - accountId: draft.accountId, - collectionId: collectionId, - object: object, - localProjectionId: projectionId, - ); + operationId = + await DavPendingOperationQueue( + database: _database, + nowUtc: () => _now().toUtc(), + ).enqueueCreate( + accountId: draft.accountId, + collectionId: collectionId, + object: object, + localProjectionId: projectionId, + ); }); - await _notificationScheduleService().rebuildUpcomingEventNotifications( - draft.accountId, - ); - await _onNotificationScheduleChanged?.call(); + if (rebuildNotifications) { + await _notificationScheduleService().rebuildUpcomingEventNotifications( + draft.accountId, + ); + await _onNotificationScheduleChanged?.call(); + } + return operationId; } Future _updateLocalDavEvent( @@ -1112,9 +3041,7 @@ class CalendarRepository { } final recurring = existing.providerRecurringEventId != null; final recurringScope = draft.recurringMutationScope; - if (recurring && - (recurringScope == null || - recurringScope == RecurringEventMutationScope.thisAndFuture)) { + if (recurring && recurringScope == null) { throw UnsupportedError( 'A supported recurring-event editing scope is required.', ); @@ -1174,12 +3101,13 @@ class CalendarRepository { baselineRawIcs: baselineRawIcs, input: input, ); - } else if (recurringScope == RecurringEventMutationScope.singleOccurrence && - existing.recurrenceIdKey == null) { + } else if (recurringScope == RecurringEventMutationScope.thisAndFuture || + (recurringScope == RecurringEventMutationScope.singleOccurrence && + existing.recurrenceIdKey == null)) { final occurrenceKey = existing.occurrenceKey; if (occurrenceKey == null || objectId == null) { throw UnsupportedError( - 'One-occurrence editing requires a synchronized occurrence.', + 'Occurrence editing requires a synchronized occurrence.', ); } patch = buildDavEventOccurrenceExceptionPatch( @@ -1187,6 +3115,8 @@ class CalendarRepository { occurrenceKey: occurrenceKey, baselineRawIcs: baselineRawIcs, input: input, + thisAndFuture: + recurringScope == RecurringEventMutationScope.thisAndFuture, nowUtc: () => _now().toUtc(), ); } else { @@ -1257,9 +3187,7 @@ class CalendarRepository { throw StateError('The DAV event projection is incomplete.'); } final recurring = existing.providerRecurringEventId != null; - if (recurring && - (recurringScope == null || - recurringScope == RecurringEventMutationScope.thisAndFuture)) { + if (recurring && recurringScope == null) { throw UnsupportedError( 'A supported recurring-event deletion scope is required.', ); @@ -1271,9 +3199,10 @@ class CalendarRepository { final objectId = existing.davObjectId; await _database.transaction(() async { if (objectId == null) { - if (recurringScope == RecurringEventMutationScope.singleOccurrence) { + if (recurringScope == RecurringEventMutationScope.singleOccurrence || + recurringScope == RecurringEventMutationScope.thisAndFuture) { throw UnsupportedError( - 'One-occurrence deletion requires a synchronized occurrence.', + 'Occurrence deletion requires a synchronized occurrence.', ); } final cancelled = await queue.cancelUnsentCreate( @@ -1300,7 +3229,8 @@ class CalendarRepository { throw StateError('The DAV event baseline is unavailable.'); } final provider = BusyProviderCodec.requireStorageValue(source.provider); - if (recurringScope == RecurringEventMutationScope.singleOccurrence) { + if (recurringScope == RecurringEventMutationScope.singleOccurrence || + recurringScope == RecurringEventMutationScope.thisAndFuture) { final occurrenceKey = existing.occurrenceKey; if (occurrenceKey == null) { throw StateError('The DAV occurrence identity is unavailable.'); @@ -1314,6 +3244,8 @@ class CalendarRepository { uid: uid, occurrenceKey: occurrenceKey, baselineRawIcs: editableRawIcs, + thisAndFuture: + recurringScope == RecurringEventMutationScope.thisAndFuture, nowUtc: () => _now().toUtc(), ); final candidate = patch.applyTo(editableRawIcs, nowUtc: _now().toUtc()); @@ -1479,6 +3411,148 @@ class CalendarRepository { ); } + Future _pendingCalendarCreate(String sourceId) { + return (_database.select(_database.pendingOps) + ..where( + (row) => + row.calendarSourceId.equals(sourceId) & + row.operationType.equals('calendar.create') & + row.state.equals('pending'), + ) + ..limit(1)) + .getSingleOrNull(); + } + + Future> _pendingCalendarWork(String sourceId) { + return (_database.select(_database.pendingOps)..where( + (row) => + row.calendarSourceId.equals(sourceId) & + row.state.equals('pending'), + )) + .get(); + } + + Future> _pendingCalendarPatchFields(String sourceId) async { + final operations = + await (_database.select(_database.pendingOps)..where( + (row) => + row.calendarSourceId.equals(sourceId) & + row.operationType.equals('calendar.patch') & + row.state.equals('pending'), + )) + .get(); + const projectedFields = { + 'summary', + 'description', + 'timeZone', + 'backgroundColor', + 'foregroundColor', + 'colorId', + }; + return { + for (final operation in operations) + ..._calendarPendingRequest( + operation, + ).keys.where(projectedFields.contains), + }; + } + + Future _hasActiveCalendarRemoval(String sourceId) async { + final operations = + await (_database.select(_database.pendingOps)..where( + (row) => + row.calendarSourceId.equals(sourceId) & + (row.operationType.equals('calendar.delete') | + row.operationType.equals('calendar.remove')) & + row.state.equals('pending'), + )) + .get(); + return operations.any( + (operation) => + operation.nextAttemptAtUtc?.startsWith('9999-12-31') != true, + ); + } + + Future _enqueueOrMergeCalendarPatch( + CalendarSource source, { + required Map request, + required String nowUtc, + String? dependsOnOpId, + }) async { + final queuedRequest = _calendarPatchRequestWithPreviousValues( + source, + request, + ); + final requestedScope = + queuedRequest[calendarMutationScopeKey]?.toString() ?? + calendarMutationScopeGlobal; + final candidates = + await (_database.select(_database.pendingOps) + ..where( + (row) => + row.calendarSourceId.equals(source.id) & + row.operationType.equals('calendar.patch') & + row.state.equals('pending') & + row.attemptCount.equals(0), + ) + ..orderBy([(row) => OrderingTerm.asc(row.createdAtUtc)])) + .get(); + PendingOp? existing; + for (final candidate in candidates) { + final candidateScope = + _calendarPendingRequest( + candidate, + )[calendarMutationScopeKey]?.toString() ?? + calendarMutationScopeGlobal; + if (candidateScope == requestedScope) { + existing = candidate; + break; + } + } + if (existing != null) { + final existingOp = existing; + final existingRequest = _calendarPendingRequest(existingOp); + final existingPrevious = _jsonObjectMap( + existingRequest[calendarPatchPreviousValuesKey], + ); + final queuedPrevious = _jsonObjectMap( + queuedRequest[calendarPatchPreviousValuesKey], + ); + final merged = existingRequest..addAll(queuedRequest); + merged[calendarPatchPreviousValuesKey] = { + ...queuedPrevious, + ...existingPrevious, + }; + await (_database.update( + _database.pendingOps, + )..where((row) => row.id.equals(existingOp.id))).write( + PendingOpsCompanion( + requestJson: Value(jsonEncode(merged)), + dependsOnOpId: Value(existingOp.dependsOnOpId ?? dependsOnOpId), + updatedAtUtc: Value(nowUtc), + ), + ); + return; + } + await _database.pendingOpsDao.enqueue( + PendingOpsCompanion.insert( + id: const Uuid().v4(), + accountId: source.accountId, + provider: Value(source.provider), + entityType: 'calendar', + operation: 'patch', + operationType: const Value('calendar.patch'), + calendarSourceId: Value(source.id), + providerCalendarId: Value(source.providerCalendarId), + dependsOnOpId: Value(dependsOnOpId), + requestJson: jsonEncode(queuedRequest), + baselineRawJson: Value(source.rawJson), + createdAtUtc: nowUtc, + updatedAtUtc: nowUtc, + ), + ); + } + NotificationScheduleService _notificationScheduleService() { return NotificationScheduleService( database: _database, @@ -1632,6 +3706,93 @@ void _requireWritableSource( throw CalendarMutationNotAllowed(operation: operation, sourceId: source.id); } +void _requireFullEventEditingAllowed(CalendarEvent event) { + final provider = BusyProviderCodec.requireStorageValue(event.provider); + if (provider != BusyProvider.google) return; + final raw = _jsonMap(event.rawJson); + final organizer = _jsonMap(event.organizerJson); + final allowed = + raw['locked'] != true && + (organizer['self'] == true || raw['guestsCanModify'] == true); + if (allowed) return; + throw CalendarMutationNotAllowed( + operation: CalendarMutationOperation.editEvent, + sourceId: event.calendarSourceId, + ); +} + +void _requireAttendeeManagementAllowed( + CalendarEvent event, + EventEditorDraft draft, +) { + if (!draft.attendeesChanged) return; + final provider = BusyProviderCodec.requireStorageValue(event.provider); + if (provider != BusyProvider.google) return; + final raw = _jsonMap(event.rawJson); + final organizer = _jsonMap(event.organizerJson); + final allowed = + organizer['self'] == true || raw['guestsCanInviteOthers'] != false; + if (allowed) return; + throw CalendarMutationNotAllowed( + operation: CalendarMutationOperation.editEvent, + sourceId: event.calendarSourceId, + ); +} + +void _requireCalendarSourceCapability( + CalendarSource source, { + required CalendarMutationOperation operation, + required bool allowed, +}) { + if (allowed) { + return; + } + throw CalendarMutationNotAllowed(operation: operation, sourceId: source.id); +} + +Map _calendarPendingRequest(PendingOp op) { + final decoded = jsonDecode(op.requestJson); + if (decoded is! Map) { + return {}; + } + return decoded.cast(); +} + +String _calendarOperationType(PendingOp op) { + return op.operationType ?? '${op.entityType}.${op.operation}'; +} + +Map _calendarPatchRequestWithPreviousValues( + CalendarSource source, + Map request, +) { + final queued = {...request}; + final previous = {}; + if (request.containsKey('summary')) { + previous['summary'] = source.summary; + } + if (request.containsKey('backgroundColor') || + request.containsKey('foregroundColor') || + request.containsKey('colorId')) { + previous['backgroundColor'] = source.backgroundColor; + previous['foregroundColor'] = source.foregroundColor; + previous['colorId'] = source.colorId; + } + if (previous.isNotEmpty) { + queued[calendarPatchPreviousValuesKey] = previous; + } + return queued; +} + +bool _calendarRemovalPreviousHidden(PendingOp op) { + final requestValue = _jsonMap( + op.requestJson, + )[calendarRemovalPreviousHiddenKey]; + if (requestValue is bool) return requestValue; + final baselineValue = _jsonMap(op.baselineRawJson)['hidden']; + return baselineValue is bool ? baselineValue : false; +} + void _requireDavSchedulingUnchanged( EventEditorDraft draft, { required bool creating, @@ -1668,6 +3829,7 @@ DavEventMutationInput _davEventInput( recurrence: draft.recurrence, recurrenceChanged: draft.recurrenceChanged, reminders: draft.reminders, + remindersChanged: draft.remindersChanged, categories: draft.categories, categoriesChanged: draft.categoriesChanged, classification: draft.visibilityOrSensitivity, @@ -1798,6 +3960,210 @@ String _pendingCreateRawIcs(PendingOp operation) { String? _json(Object? value) => value == null ? null : jsonEncode(value); +CalendarEventDetail _eventEditBaseline( + EventEditorDraft draft, + CalendarEvent existing, +) { + final original = draft.originalDetail; + if (original != null && original.id == existing.id) { + return original; + } + return CalendarEventDetail.fromRow(existing); +} + +String? _eventDetailBaselineRawJson(CalendarEventDetail detail) { + final baseline = detail.baselineRaw ?? detail.raw; + return baseline == null ? null : jsonEncode(baseline); +} + +DateTime? _eventDetailStartDateTime(CalendarEventDetail detail) { + return providerDateTimeAsWallTime( + detail.allDay ? detail.startDate : detail.startDateTime, + detail.startTimeZone, + ); +} + +DateTime? _eventDetailEndDateTime(CalendarEventDetail detail) { + return providerDateTimeAsWallTime( + detail.allDay ? detail.endDate : detail.endDateTime, + detail.endTimeZone, + ); +} + +CalendarEventsCompanion _eventPatchProjection({ + required EventEditorDraft draft, + required CalendarEvent existing, + required BusyProvider provider, + required Map request, + required String? startTimeZone, + required String? endTimeZone, + required Object? conference, + required int now, +}) { + final rangeChanged = request.containsKey('allDay'); + final rawChanged = switch (provider) { + BusyProvider.google => request.containsKey('hideAttendees'), + BusyProvider.microsoft => + request.containsKey('importance') || + request.containsKey('responseRequested') || + request.containsKey('hideAttendees') || + request.containsKey('allowNewTimeProposals'), + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => false, + }; + return CalendarEventsCompanion( + title: request.containsKey('title') + ? Value(draft.title.trim()) + : const Value.absent(), + description: request.containsKey('description') + ? Value(draft.description) + : const Value.absent(), + location: request.containsKey('location') + ? Value(draft.location) + : const Value.absent(), + allDay: rangeChanged ? Value(draft.allDay) : const Value.absent(), + startDate: rangeChanged + ? Value(draft.allDay ? _date(draft.start) : null) + : const Value.absent(), + startDateTime: rangeChanged + ? Value(draft.allDay ? null : draft.start?.toIso8601String()) + : const Value.absent(), + startTimeZone: rangeChanged ? Value(startTimeZone) : const Value.absent(), + endDate: rangeChanged + ? Value(draft.allDay ? _date(draft.end) : null) + : const Value.absent(), + endDateTime: rangeChanged + ? Value(draft.allDay ? null : draft.end?.toIso8601String()) + : const Value.absent(), + endTimeZone: rangeChanged ? Value(endTimeZone) : const Value.absent(), + recurrenceJson: request.containsKey(calendarEventRecurrenceField) + ? Value(_json(draft.recurrence)) + : const Value.absent(), + remindersJson: request.containsKey('remindersJson') + ? Value(_json(draft.reminders)) + : const Value.absent(), + attendeesJson: request.containsKey(calendarEventAttendeesField) + ? Value(_json(_localAttendeesJson(draft, provider))) + : const Value.absent(), + categoriesJson: request.containsKey('categoriesJson') + ? Value(_json(_categoriesJson(draft, provider))) + : const Value.absent(), + colorId: request.containsKey('colorId') + ? Value(draft.colorId) + : const Value.absent(), + visibility: + request.containsKey('visibility') || request.containsKey('sensitivity') + ? Value(draft.visibilityOrSensitivity) + : const Value.absent(), + transparencyOrShowAs: request.containsKey('transparencyOrShowAs') + ? Value(draft.showAs) + : const Value.absent(), + conferenceJson: request.containsKey('conferenceJson') + ? Value(_json(conference)) + : const Value.absent(), + rawJson: rawChanged + ? Value( + jsonEncode( + _optimisticEventRawForPatch( + draft, + provider, + request: request, + existingJson: existing.rawJson, + ), + ), + ) + : const Value.absent(), + updatedAtLocal: Value(now), + syncStatus: const Value('pending'), + ); +} + +Map _eventDeltaRequest( + EventEditorDraft draft, + CalendarEventDetail original, + BusyProvider provider, { + required String? startTimeZone, + required String? endTimeZone, + required Object? conference, + required CalendarGuestUpdatePolicy guestUpdatePolicy, +}) { + final full = _eventRequest( + draft, + provider, + isCreate: false, + startTimeZone: startTimeZone, + endTimeZone: endTimeZone, + conference: conference, + guestUpdatePolicy: guestUpdatePolicy, + ); + final result = { + calendarEventGuestUpdatePolicyKey: guestUpdatePolicy.name, + }; + void copy(String key) { + if (full.containsKey(key)) result[key] = full[key]; + } + + if (draft.title.trim() != original.title) copy('title'); + if ((draft.description ?? '') != (original.description ?? '')) { + result['description'] = draft.description ?? ''; + copy('descriptionContentType'); + copy('descriptionHtml'); + } + if ((draft.location ?? '') != (original.location ?? '')) { + result['location'] = draft.location ?? ''; + } + final rangeChanged = + draft.allDay != original.allDay || + draft.start != _eventDetailStartDateTime(original) || + draft.end != _eventDetailEndDateTime(original) || + draft.startTimeZone != original.startTimeZone || + draft.endTimeZone != original.endTimeZone; + if (rangeChanged) { + copy('allDay'); + copy('start'); + copy('end'); + copy('startTimeZone'); + copy('endTimeZone'); + } + if (draft.recurrenceChanged) copy(calendarEventRecurrenceField); + if (!_sameJsonValue(original.reminders, draft.reminders)) { + copy('remindersJson'); + } + if (draft.attendeesChanged) copy(calendarEventAttendeesField); + if (draft.colorId != original.colorId) copy('colorId'); + if (draft.categoriesChanged) copy('categoriesJson'); + if (draft.visibilityOrSensitivity != original.visibility) { + copy(provider == BusyProvider.google ? 'visibility' : 'sensitivity'); + } + if (draft.showAs != original.transparencyOrShowAs) { + copy('transparencyOrShowAs'); + } + final raw = _jsonObjectMap(original.raw); + if (draft.importance != raw['importance']?.toString()) copy('importance'); + if (conference != null) copy('conferenceJson'); + if (draft.responseRequested != raw['responseRequested']) { + copy('responseRequested'); + } + final existingHidden = provider == BusyProvider.google + ? raw['guestsCanSeeOtherGuests'] is bool + ? !(raw['guestsCanSeeOtherGuests'] as bool) + : null + : raw['hideAttendees']; + if (draft.hideAttendees != existingHidden) copy('hideAttendees'); + if (draft.allowNewTimeProposals != raw['allowNewTimeProposals']) { + copy('allowNewTimeProposals'); + } + final clearFields = { + for (final value in full[calendarEventClearFieldsKey] as List? ?? const []) + value.toString(), + }..removeWhere((field) => !result.containsKey(field)); + if (clearFields.isNotEmpty) { + result[calendarEventClearFieldsKey] = clearFields.toList(); + } + return result; +} + Map _eventRequest( EventEditorDraft draft, BusyProvider provider, { @@ -1860,7 +4226,9 @@ Object? _conferenceRequest(EventEditorDraft draft, BusyProvider provider) { }, }, BusyProvider.microsoft => 'teamsForBusiness', - BusyProvider.appleICloud || BusyProvider.nextcloud => null, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, }; } @@ -1889,12 +4257,9 @@ String? _effectiveEndTimeZone( return null; } final explicit = _nonBlank(draft.endTimeZone); - if (explicit != null && !_isUtcTimeZone(explicit)) { - return explicit; - } - return _nonBlank(startTimeZone) ?? + return explicit ?? + _nonBlank(startTimeZone) ?? _nonBlank(localTimeZone) ?? - explicit ?? _nonBlank(sourceTimeZone) ?? 'UTC'; } @@ -1905,23 +4270,12 @@ String _effectiveTimedEventZone({ required String? localTimeZone, }) { final explicit = _nonBlank(explicitTimeZone); - if (explicit != null && !_isUtcTimeZone(explicit)) { - return explicit; - } - return _nonBlank(localTimeZone) ?? - explicit ?? + return explicit ?? + _nonBlank(localTimeZone) ?? _nonBlank(sourceTimeZone) ?? 'UTC'; } -bool _isUtcTimeZone(String value) { - final normalized = value.trim().toLowerCase(); - return normalized == 'utc' || - normalized == 'etc/utc' || - normalized == 'gmt' || - normalized == 'etc/gmt'; -} - String? _nonBlank(String? value) { final trimmed = value?.trim(); return trimmed == null || trimmed.isEmpty ? null : trimmed; @@ -2000,11 +4354,76 @@ Map _optimisticEventRaw( } case BusyProvider.appleICloud: case BusyProvider.nextcloud: + case BusyProvider.webCal: + break; + } + return raw; +} + +Map _optimisticEventRawForPatch( + EventEditorDraft draft, + BusyProvider provider, { + required Map request, + String? existingJson, +}) { + final raw = {..._jsonMap(existingJson)}; + switch (provider) { + case BusyProvider.google: + if (request.containsKey('hideAttendees')) { + final hidden = draft.hideAttendees; + if (hidden == null) { + raw.remove('guestsCanSeeOtherGuests'); + } else { + raw['guestsCanSeeOtherGuests'] = !hidden; + } + } + case BusyProvider.microsoft: + _setOrRemoveRawField( + raw, + request: request, + key: 'importance', + value: draft.importance, + ); + _setOrRemoveRawField( + raw, + request: request, + key: 'responseRequested', + value: draft.responseRequested, + ); + _setOrRemoveRawField( + raw, + request: request, + key: 'hideAttendees', + value: draft.hideAttendees, + ); + _setOrRemoveRawField( + raw, + request: request, + key: 'allowNewTimeProposals', + value: draft.allowNewTimeProposals, + ); + case BusyProvider.appleICloud: + case BusyProvider.nextcloud: + case BusyProvider.webCal: break; } return raw; } +void _setOrRemoveRawField( + Map raw, { + required Map request, + required String key, + required Object? value, +}) { + if (!request.containsKey(key)) return; + if (value == null) { + raw.remove(key); + } else { + raw[key] = value; + } +} + List> _jsonMapList(String? value) { if (value == null || value.isEmpty) return const []; try { @@ -2029,6 +4448,10 @@ Map _jsonMap(String? value) { } } +Map _jsonObjectMap(Object? value) { + return value is Map ? Map.from(value) : const {}; +} + List _conferenceSolutions(String? rawJson) { final raw = _jsonMap(rawJson); final allowed = raw['allowedOnlineMeetingProviders']; @@ -2101,6 +4524,164 @@ Object? _categoriesJson(EventEditorDraft draft, BusyProvider provider) { return draft.categories; } +String? _storedEventStart(CalendarEvent event) => + event.allDay ? event.startDate : event.startDateTime; + +String? _storedEventEnd(CalendarEvent event) => + event.allDay ? event.endDate : event.endDateTime; + +DateTime? _storedEventStartDateTime(CalendarEvent event) => + providerDateTimeAsWallTime(_storedEventStart(event), event.startTimeZone); + +DateTime? _storedEventEndDateTime(CalendarEvent event) => + providerDateTimeAsWallTime(_storedEventEnd(event), event.endTimeZone); + +bool _seriesRowInScope( + CalendarEvent row, + CalendarEvent target, + RecurringEventMutationScope scope, +) { + if (scope == RecurringEventMutationScope.entireSeries) return true; + if (scope != RecurringEventMutationScope.thisAndFuture) { + return row.id == target.id; + } + final targetStart = _seriesOriginalStart(target); + final rowStart = _seriesOriginalStart(row); + if (targetStart == null || rowStart == null) return row.id == target.id; + return !rowStart.isBefore(targetStart); +} + +bool _googleEventCanSplit(CalendarEvent event) { + final eventType = event.eventType; + if (eventType != null && eventType.isNotEmpty && eventType != 'default') { + return false; + } + final conference = _decodeStoredJson(event.conferenceJson); + if (conference == null) return true; + if (conference is! Map || conference.isEmpty) return conference is Map; + final solution = conference['conferenceSolution']; + final key = solution is Map ? solution['key'] : null; + return key is Map && key['type']?.toString() == 'hangoutsMeet'; +} + +DateTime? _seriesOriginalStart(CalendarEvent event) { + return DateTime.tryParse(event.providerOriginalStartKey ?? '') ?? + _storedEventStartDateTime(event); +} + +Object? _decodeStoredJson(String? value) { + if (value == null || value.isEmpty) return null; + try { + return jsonDecode(value); + } on FormatException { + return null; + } +} + +bool _sameJsonValue(Object? left, Object? right) { + return jsonEncode(_normalizedJson(left)) == + jsonEncode(_normalizedJson(right)); +} + +bool _eventRequestHasMutation(Map request) { + const metadata = { + calendarEventGuestUpdatePolicyKey, + calendarEventRecurringScopeKey, + calendarEventTargetProviderIdKey, + calendarEventOriginalStartKey, + calendarEventOriginalEndKey, + calendarEventDestinationCalendarIdKey, + calendarEventDestinationSourceIdKey, + }; + return request.entries.any( + (entry) => + !metadata.contains(entry.key) && + (entry.value != null || entry.key == calendarEventClearFieldsKey), + ); +} + +Object _eventRemindersForProvider(Object? reminders, BusyProvider provider) { + final minutes = []; + if (reminders is Map) { + final map = reminders.cast(); + final single = map['reminderMinutesBeforeStart']; + 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)) { + minutes.add(value); + } + } + } + final davMinutes = map['minutes']; + if (davMinutes is List) { + for (final value in davMinutes.whereType()) { + if (value > 0 && !minutes.contains(value)) minutes.add(value); + } + } + } + if (provider == BusyProvider.microsoft) { + return minutes.isEmpty + ? const {'isReminderOn': false} + : {'isReminderOn': true, 'reminderMinutesBeforeStart': minutes.first}; + } + return { + 'useDefault': false, + 'overrides': [ + for (final value in minutes) {'method': 'popup', 'minutes': value}, + ], + }; +} + +String _eventShowAsForProvider(String? value, BusyProvider provider) { + if (provider == BusyProvider.microsoft) { + return switch (value) { + 'free' || 'tentative' || 'busy' || 'oof' || 'workingElsewhere' => value!, + 'transparent' => 'free', + _ => 'busy', + }; + } + return switch (value) { + 'opaque' || 'transparent' => value!, + 'free' => 'transparent', + _ => 'opaque', + }; +} + +String _eventVisibilityForProvider(String? value, BusyProvider provider) { + if (provider == BusyProvider.microsoft) { + return switch (value) { + 'normal' || 'personal' || 'private' || 'confidential' => value!, + _ => 'normal', + }; + } + return switch (value) { + 'default' || 'public' || 'private' || 'confidential' => value!, + 'personal' => 'private', + _ => 'default', + }; +} + +Object? _normalizedJson(Object? value) { + if (value is Map) { + final entries = value.entries.toList() + ..sort( + (left, right) => left.key.toString().compareTo(right.key.toString()), + ); + return { + for (final entry in entries) + entry.key.toString(): _normalizedJson(entry.value), + }; + } + if (value is List) { + return [for (final item in value) _normalizedJson(item)]; + } + return value; +} + String? _date(DateTime? value) { if (value == null) { return null; diff --git a/lib/src/features/calendar/domain/event_move_policy.dart b/lib/src/features/calendar/domain/event_move_policy.dart new file mode 100644 index 00000000..24ef38ea --- /dev/null +++ b/lib/src/features/calendar/domain/event_move_policy.dart @@ -0,0 +1,51 @@ +import '../../../providers/busy_provider.dart'; +import '../presentation/event_editor_draft.dart'; + +enum CalendarEventMoveStrategy { none, googleNative, davNative, copyThenDelete } + +CalendarEventMoveStrategy calendarEventMoveStrategy({ + required String sourceAccountId, + required String sourceId, + required String sourceProviderCalendarId, + required BusyProvider sourceProvider, + required String? sourceDavCollectionId, + required String destinationAccountId, + required String destinationId, + required String destinationProviderCalendarId, + required BusyProvider destinationProvider, + required String? destinationDavCollectionId, + required String? eventType, + required bool recurring, + required RecurringEventMutationScope? recurringScope, +}) { + final changed = + sourceAccountId != destinationAccountId || + sourceId != destinationId || + sourceProviderCalendarId != destinationProviderCalendarId || + sourceProvider != destinationProvider; + if (!changed) return CalendarEventMoveStrategy.none; + + final movesWholeResource = + !recurring || recurringScope == RecurringEventMutationScope.entireSeries; + if (!movesWholeResource || sourceAccountId != destinationAccountId) { + return CalendarEventMoveStrategy.copyThenDelete; + } + + if (sourceProvider == BusyProvider.google && + destinationProvider == BusyProvider.google && + (eventType == null || eventType.isEmpty || eventType == 'default')) { + return CalendarEventMoveStrategy.googleNative; + } + + final davProvider = + sourceProvider == BusyProvider.nextcloud || + sourceProvider == BusyProvider.appleICloud; + if (davProvider && + destinationProvider == sourceProvider && + sourceDavCollectionId != null && + destinationDavCollectionId != null) { + return CalendarEventMoveStrategy.davNative; + } + + return CalendarEventMoveStrategy.copyThenDelete; +} diff --git a/lib/src/features/calendar/presentation/calendar_color_dialog.dart b/lib/src/features/calendar/presentation/calendar_color_dialog.dart new file mode 100644 index 00000000..1774a912 --- /dev/null +++ b/lib/src/features/calendar/presentation/calendar_color_dialog.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; + +import '../../../app/busymax_dialogs.dart'; +import '../../../app/busymax_design.dart'; +import '../../../calendar_providers/calendar_colors.dart'; +import '../../../l10n/l10n.dart'; +import '../../../platform/linux_header_bar_service.dart'; +import '../../../providers/busy_provider.dart'; + +Future showCalendarColorDialog( + BuildContext context, { + required BusyProvider provider, + required String? currentBackgroundColor, + required String? currentColorId, + required LinuxHeaderBarService headerBarService, +}) { + return showBusyMaxModalDialog( + context, + headerBarService: headerBarService, + barrierDismissible: false, + builder: (dialogContext) => _CalendarColorDialog( + provider: provider, + currentBackgroundColor: currentBackgroundColor, + currentColorId: currentColorId, + ), + ); +} + +class _CalendarColorDialog extends StatefulWidget { + const _CalendarColorDialog({ + required this.provider, + required this.currentBackgroundColor, + required this.currentColorId, + }); + + final BusyProvider provider; + final String? currentBackgroundColor; + final String? currentColorId; + + @override + State<_CalendarColorDialog> createState() => _CalendarColorDialogState(); +} + +class _CalendarColorDialogState extends State<_CalendarColorDialog> { + late final List _choices; + CalendarColorChoice? _selected; + + @override + void initState() { + super.initState(); + _choices = calendarColorChoices(widget.provider); + _selected = switch (widget.provider) { + BusyProvider.google => _findChoice( + (choice) => + _sameHex(choice.backgroundColor, widget.currentBackgroundColor), + ), + BusyProvider.microsoft => _findChoice( + (choice) => choice.providerValue == widget.currentColorId, + ), + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, + }; + } + + CalendarColorChoice? _findChoice( + bool Function(CalendarColorChoice choice) matches, + ) { + for (final choice in _choices) { + if (matches(choice)) return choice; + } + return null; + } + + @override + Widget build(BuildContext context) { + return BusyMaxDialogShell( + title: context.l10n.calendarColor, + maxWidth: 360, + actions: [ + BusyMaxPushButton.standard( + onPressed: () => Navigator.of(context).pop(), + child: Text(context.l10n.cancel), + ), + BusyMaxPushButton.suggested( + onPressed: _selected == null + ? null + : () => Navigator.of(context).pop(_selected), + child: Text(context.l10n.save), + ), + ], + children: [ + Wrap( + alignment: WrapAlignment.center, + spacing: BusyMaxSpacing.sm, + runSpacing: BusyMaxSpacing.sm, + children: [ + for (var index = 0; index < _choices.length; index += 1) + _CalendarColorSwatch( + choice: _choices[index], + label: context.l10n.calendarColorOption(index + 1), + selected: _selected == _choices[index], + onSelected: () => setState(() => _selected = _choices[index]), + ), + ], + ), + ], + ); + } +} + +class _CalendarColorSwatch extends StatelessWidget { + const _CalendarColorSwatch({ + required this.choice, + required this.label, + required this.selected, + required this.onSelected, + }); + + final CalendarColorChoice choice; + final String label; + final bool selected; + final VoidCallback onSelected; + + @override + Widget build(BuildContext context) { + final color = _parseColor(choice.backgroundColor); + final checkColor = + ThemeData.estimateBrightnessForColor(color) == Brightness.dark + ? Colors.white + : Colors.black; + return Semantics( + label: label, + selected: selected, + button: true, + child: Tooltip( + message: label, + child: InkResponse( + onTap: onSelected, + radius: 27, + customBorder: const CircleBorder(), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: 44, + height: 44, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: selected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outlineVariant, + width: selected ? 3 : 1, + ), + ), + child: DecoratedBox( + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + child: selected + ? Icon(Icons.check, color: checkColor, size: 20) + : null, + ), + ), + ), + ), + ); + } +} + +Color _parseColor(String value) { + final normalized = value.replaceFirst('#', ''); + return Color(int.parse('ff$normalized', radix: 16)); +} + +bool _sameHex(String first, String? second) { + return second != null && first.toLowerCase() == second.toLowerCase(); +} diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index 4c3eff56..5a340551 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -13,8 +13,12 @@ import '../../../platform/linux_header_bar_service.dart'; import '../../../schedule/schedule_projection.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import '../../accounts/data/accounts_repository.dart'; +import '../../recurrence/domain/event_recurrence_codec.dart'; +import '../../recurrence/domain/recurrence_rule.dart'; +import '../../recurrence/presentation/recurrence_editor.dart'; import '../../tasks/presentation/desktop_date_time_fields.dart'; import '../data/calendar_repository.dart'; +import '../domain/event_move_policy.dart'; import 'event_description_editor.dart'; import 'event_editor_draft.dart'; import 'event_guest_delivery_dialog.dart'; @@ -47,6 +51,7 @@ Future showBusyMaxEventEditorDialog( draft: draft, initialDraft: initialDraft, sources: sources, + accounts: accounts, headerBarService: headerBarService, ), ), @@ -110,10 +115,30 @@ Future _completeEventEditorSave( required EventEditorDraft draft, required EventEditorDraft initialDraft, required List sources, + required List accounts, LinuxHeaderBarService? headerBarService, }) async { + final move = _eventMoveContext( + initialDraft: initialDraft, + draft: draft, + sources: sources, + ); + if (move != null && + move.strategy == CalendarEventMoveStrategy.copyThenDelete) { + final confirmed = await showBusyMaxConfirm( + context, + title: context.l10n.copyEventAndDeleteOriginal, + message: context.l10n.copyEventMoveWarning( + _calendarMoveLabel(move.source, accounts), + _calendarMoveLabel(move.destination, accounts), + ), + confirmLabel: context.l10n.copyAndDelete, + headerBarService: headerBarService, + ); + if (!confirmed || !context.mounted) return; + } var guestUpdatePolicy = CalendarGuestUpdatePolicy.send; - final provider = _providerForDraft(draft, sources); + final provider = _guestDeliveryProvider(move, draft, sources); if (provider != null && draft.isOrganizer == true && _hasExternalGuests(draft.attendees, initialDraft.attendees)) { @@ -132,6 +157,82 @@ Future _completeEventEditorSave( ); } +BusyProvider? _guestDeliveryProvider( + _EventMoveContext? move, + EventEditorDraft draft, + List sources, +) { + final destination = _providerForDraft(draft, sources); + if (destination == BusyProvider.google || + destination == BusyProvider.microsoft) { + return destination; + } + final source = move?.source.provider; + return source == BusyProvider.google || source == BusyProvider.microsoft + ? source + : null; +} + +_EventMoveContext? _eventMoveContext({ + required EventEditorDraft initialDraft, + required EventEditorDraft draft, + required List sources, +}) { + if (initialDraft.eventId == null) return null; + CalendarSourceEntity? source; + CalendarSourceEntity? destination; + for (final candidate in sources) { + if (candidate.id == initialDraft.sourceId) source = candidate; + if (candidate.id == draft.sourceId) destination = candidate; + } + if (source == null || destination == null) return null; + final strategy = calendarEventMoveStrategy( + sourceAccountId: source.accountId, + sourceId: source.id, + sourceProviderCalendarId: source.providerCalendarId, + sourceProvider: source.provider, + sourceDavCollectionId: source.davCollectionId, + destinationAccountId: destination.accountId, + destinationId: destination.id, + destinationProviderCalendarId: destination.providerCalendarId, + destinationProvider: destination.provider, + destinationDavCollectionId: destination.davCollectionId, + eventType: initialDraft.eventType, + recurring: initialDraft.providerRecurringEventId != null, + recurringScope: draft.recurringMutationScope, + ); + if (strategy == CalendarEventMoveStrategy.none) return null; + return _EventMoveContext( + source: source, + destination: destination, + strategy: strategy, + ); +} + +String _calendarMoveLabel( + CalendarSourceEntity source, + List accounts, +) { + for (final account in accounts) { + if (account.id == source.accountId) { + return '${account.selectorLabel} · ${source.summary}'; + } + } + return '${source.provider.displayName} · ${source.summary}'; +} + +class _EventMoveContext { + const _EventMoveContext({ + required this.source, + required this.destination, + required this.strategy, + }); + + final CalendarSourceEntity source; + final CalendarSourceEntity destination; + final CalendarEventMoveStrategy strategy; +} + Future _completeEventEditorDelete( BuildContext context, { required String eventId, @@ -224,6 +325,17 @@ class _EventEditorState extends State { var _startTimeValid = true; var _endTimeValid = true; + List get _selectableSources { + if (widget.initialDraft.eventId == null) { + return widget.sources; + } + return [ + for (final source in widget.sources) + if (!source.pendingCreate || source.id == widget.initialDraft.sourceId) + source, + ]; + } + @override void initState() { super.initState(); @@ -245,7 +357,7 @@ class _EventEditorState extends State { ? l10n.newEvent : l10n.editEvent; CalendarSourceEntity? currentSource; - for (final source in widget.sources) { + for (final source in _selectableSources) { if (source.id == _draft.sourceId) { currentSource = source; break; @@ -277,14 +389,15 @@ class _EventEditorState extends State { final schedulingReadOnly = provider == BusyProvider.appleICloud || provider == BusyProvider.nextcloud; - final davRecurring = - schedulingReadOnly && _draft.providerRecurringEventId != null; + final recurringOccurrence = _draft.providerRecurringEventId != null; final timeFieldsValid = _draft.allDay || (_startTimeValid && _endTimeValid); final recurringScopeValid = - !davRecurring || + !recurringOccurrence || (_draft.recurringMutationScope != null && - _draft.recurringMutationScope != - RecurringEventMutationScope.thisAndFuture); + _supportsRecurringScope( + currentSource, + _draft.recurringMutationScope!, + )); final canSave = dirty && _draft.canSave && timeFieldsValid && recurringScopeValid; return CallbackShortcuts( @@ -352,7 +465,7 @@ class _EventEditorState extends State { children: [ BusyMaxTimeModeRow( allDay: _draft.allDay, - onChanged: _setAllDay, + onChanged: (value) => _setAllDay(value, provider), ), ], ), @@ -376,9 +489,7 @@ class _EventEditorState extends State { }, timeZone: _draft.startTimeZone, onTimeZoneChanged: (value) { - setState(() { - _draft = _draft.copyWith(startTimeZone: value); - }); + _setStartTimeZone(value, provider); }, allowEmpty: false, onValidityChanged: (valid) { @@ -422,11 +533,11 @@ class _EventEditorState extends State { ), ], ), - if (davRecurring) + if (recurringOccurrence) BusyMaxGroupedList( title: l10n.recurringEventScope, filled: true, - children: _recurringScopeRows(), + children: _recurringScopeRows(provider), ), if (_draft.providerRecurringEventId == null) BusyMaxGroupedList( @@ -442,7 +553,9 @@ class _EventEditorState extends State { BusyMaxGroupedList( title: l10n.guests, filled: true, - children: _guestRows(readOnly: schedulingReadOnly), + children: _guestRows( + readOnly: schedulingReadOnly || !_draft.canManageAttendees, + ), ), if ((provider == BusyProvider.google || provider == BusyProvider.microsoft) && @@ -510,7 +623,9 @@ class _EventEditorState extends State { ), ), destructive: true, - onTap: davRecurring && _draft.recurringMutationScope == null + onTap: + recurringOccurrence && + _draft.recurringMutationScope == null ? null : _deleteCurrentEvent, ), @@ -588,93 +703,160 @@ class _EventEditorState extends State { } bool get _requiresRecurringScope { - if (_draft.providerRecurringEventId == null) return false; - for (final source in widget.sources) { - if (source.id != _draft.sourceId) continue; - return source.provider == BusyProvider.appleICloud || - source.provider == BusyProvider.nextcloud; - } - return false; + return _draft.providerRecurringEventId != null; } - List _recurringScopeRows() { + List _recurringScopeRows(BusyProvider provider) { final l10n = context.l10n; + final source = _sourceForId(_draft.sourceId); + final supportsFollowing = + source != null && + _supportsRecurringScope( + source, + RecurringEventMutationScope.thisAndFuture, + ); + final supportsEntire = + source != null && + _supportsRecurringScope( + source, + RecurringEventMutationScope.entireSeries, + ); + final moving = _isMovingTo(source); return [ BusyMaxActionRow( - title: l10n.entireSeries, + title: l10n.singleOccurrence, subtitle: l10n.chooseRecurringEventScope, leading: Icon( _draft.recurringMutationScope == - RecurringEventMutationScope.entireSeries + RecurringEventMutationScope.singleOccurrence ? Icons.radio_button_checked : Icons.radio_button_unchecked, ), onTap: () => setState(() { _draft = _draft.copyWith( - recurringMutationScope: RecurringEventMutationScope.entireSeries, + recurringMutationScope: + RecurringEventMutationScope.singleOccurrence, ); }), ), BusyMaxActionRow( - title: l10n.singleOccurrence, + title: l10n.thisAndFollowingEvents, + subtitle: supportsFollowing + ? null + : moving + ? l10n.thisAndFutureMoveUnavailable + : l10n.thisAndFutureUnavailable, leading: Icon( _draft.recurringMutationScope == - RecurringEventMutationScope.singleOccurrence + RecurringEventMutationScope.thisAndFuture ? Icons.radio_button_checked - : Icons.radio_button_unchecked, + : supportsFollowing + ? Icons.radio_button_unchecked + : Icons.update_disabled_outlined, ), - onTap: () => setState(() { - _draft = _draft.copyWith( - recurringMutationScope: - RecurringEventMutationScope.singleOccurrence, - ); - }), + enabled: supportsFollowing, + onTap: supportsFollowing + ? () => setState(() { + _draft = _draft.copyWith( + recurringMutationScope: + RecurringEventMutationScope.thisAndFuture, + ); + }) + : null, ), BusyMaxActionRow( - title: l10n.thisAndFutureUnavailable, - leading: const Icon(Icons.update_disabled_outlined), - enabled: false, + title: l10n.entireSeries, + subtitle: supportsEntire ? null : l10n.entireSeriesMoveUnavailable, + leading: Icon( + _draft.recurringMutationScope == + RecurringEventMutationScope.entireSeries + ? Icons.radio_button_checked + : supportsEntire + ? Icons.radio_button_unchecked + : Icons.update_disabled_outlined, + ), + enabled: supportsEntire, + onTap: supportsEntire + ? () => setState(() { + _draft = _draft.copyWith( + recurringMutationScope: + RecurringEventMutationScope.entireSeries, + ); + }) + : null, ), ]; } Widget _repeatRow(BusyProvider provider) { final l10n = context.l10n; - final labels = { - 'none': l10n.doesNotRepeat, - 'daily': l10n.repeatDaily, - 'weekly': l10n.repeatWeekly, - 'monthly': l10n.repeatMonthly, - 'yearly': l10n.repeatYearly, - }; - return BusyMaxComboRow( + final recurrence = EventRecurrenceCodec.decode( + provider, + _draft.recurrence, + baseDate: _draft.start, + ); + if (!recurrence.isSupported) { + return BusyMaxActionRow( + title: l10n.repeat, + subtitle: l10n.unsupportedRecurrencePreserved, + leading: const Icon(Icons.repeat), + enabled: false, + ); + } + return BusyMaxActionRow( title: l10n.repeat, leading: const Icon(Icons.repeat), - values: labels.keys.toList(), - selected: _recurrenceType(_draft.recurrence), - labelFor: (value) => labels[value] ?? l10n.doesNotRepeat, - onSelected: (value) { - setState(() { - _draft = value == 'none' - ? _draft.copyWith(clearRecurrence: true) - : _draft.copyWith( - recurrence: _recurrenceFor(provider, value, _draft.start), - ); - }); - }, + subtitle: recurrenceRuleSummary( + context, + recurrence, + timeZone: _draft.startTimeZone, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => unawaited(_editRecurrence(provider, recurrence)), ); } + Future _editRecurrence( + BusyProvider provider, + RecurrenceRule initial, + ) async { + final baseDate = _draft.start ?? DateTime.now(); + final result = await showRecurrenceEditorDialog( + context, + initial: initial, + allDay: _draft.allDay, + floating: _isFloatingDavTime( + provider, + allDay: _draft.allDay, + timeZone: _draft.startTimeZone, + ), + baseDate: baseDate, + minimumDate: baseDate, + timeZone: _draft.startTimeZone, + limits: EventRecurrenceCodec.limitsFor(provider), + useNativeDatePicker: false, + headerBarService: widget.headerBarService, + ); + if (result == null || !mounted) return; + if (!result.repeats) { + setState(() => _draft = _draft.copyWith(clearRecurrence: true)); + return; + } + final encoded = EventRecurrenceCodec.encode( + provider, + result, + baseDate: baseDate, + allDay: _draft.allDay, + timeZone: _draft.startTimeZone, + original: _draft.recurrence, + ); + setState(() => _draft = _draft.copyWith(recurrence: encoded)); + } + Widget _accountRow() { - final existingAccountId = widget.initialDraft.eventId == null - ? null - : widget.initialDraft.accountId; final accountIds = { - for (final source in widget.sources) - if (existingAccountId == null || - source.accountId == existingAccountId) - source.accountId, + for (final source in _selectableSources) source.accountId, }.toList()..sort((first, second) { final labelOrder = _accountLabel( first, @@ -689,21 +871,16 @@ class _EventEditorState extends State { leading: const Icon(YaruIcons.user), values: accountIds, selected: selected, - enabled: existingAccountId == null, + enabled: accountIds.length > 1, labelFor: _accountLabel, onSelected: _selectAccount, ); } Widget _calendarRow() { - final existingSourceId = widget.initialDraft.eventId == null - ? null - : widget.initialDraft.sourceId; final sources = [ - for (final source in widget.sources) - if (source.accountId == _draft.accountId && - (existingSourceId == null || source.id == existingSourceId)) - source, + for (final source in _selectableSources) + if (source.accountId == _draft.accountId) source, ]..sort(_compareCalendarSources); if (sources.isEmpty) { return BusyMaxActionRow( @@ -721,7 +898,7 @@ class _EventEditorState extends State { leading: const Icon(YaruIcons.calendar), values: [for (final source in sources) source.id], selected: selected, - enabled: existingSourceId == null, + enabled: sources.length > 1, labelFor: (value) => sourcesById[value]!.summary, selectorLeadingBuilder: (context, value) { final source = sourcesById[value]!; @@ -748,7 +925,7 @@ class _EventEditorState extends State { return candidate.selectorLabel; } } - for (final source in widget.sources) { + for (final source in _selectableSources) { if (source.accountId == accountId) return source.provider.displayName; } return context.l10n.account; @@ -757,7 +934,7 @@ class _EventEditorState extends State { void _selectAccount(String accountId) { if (accountId == _draft.accountId) return; final sources = [ - for (final source in widget.sources) + for (final source in _selectableSources) if (source.accountId == accountId) source, ]..sort(_compareCalendarSources); if (sources.isEmpty) return; @@ -774,27 +951,139 @@ class _EventEditorState extends State { } void _selectCalendarSource(CalendarSourceEntity source) { - final recurrenceType = _recurrenceType(_draft.recurrence); - final adjustedRecurrence = - _draft.recurrenceChanged && recurrenceType != 'none' - ? _recurrenceFor(source.provider, recurrenceType, _draft.start) - : null; + final currentProvider = _providerForDraft(_draft, _selectableSources); + Object? adjustedRecurrence; + var clearRecurrence = false; + if (_draft.recurrence != null && + currentProvider != null && + currentProvider != source.provider) { + final rule = EventRecurrenceCodec.decode( + currentProvider, + _draft.recurrence, + baseDate: _draft.start, + ); + final canConvert = + rule.isSupported && + (!rule.repeats || + EventRecurrenceCodec.canEncode(source.provider, rule)); + if (!canConvert) { + if (_draft.providerRecurringEventId == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.recurrenceUnsupportedByProvider( + source.provider.displayName, + ), + ), + ), + ); + return; + } + } else if (!rule.repeats) { + clearRecurrence = true; + } else { + adjustedRecurrence = EventRecurrenceCodec.encode( + source.provider, + rule, + baseDate: _draft.start ?? DateTime.now(), + allDay: _draft.allDay, + timeZone: _draft.startTimeZone, + ); + } + } + final providerChanged = + currentProvider != null && currentProvider != source.provider; + final reminderMinutes = _reminderMinutesList(_draft.reminders); + final adjustedReminders = providerChanged + ? _remindersFor(source.provider, reminderMinutes) ?? + _disabledRemindersFor(source.provider) + : _draft.reminders; + final adjustedShowAs = providerChanged + ? _showAsForProvider(_draft.showAs, source.provider) + : _draft.showAs; + final adjustedVisibility = providerChanged + ? _visibilityForProvider( + _draft.visibilityOrSensitivity, + source.provider, + ) + : _draft.visibilityOrSensitivity; + var updated = _draft.copyWith( + accountId: source.accountId, + sourceId: source.id, + providerCalendarId: source.providerCalendarId, + reminders: providerChanged ? adjustedReminders : null, + showAs: adjustedShowAs, + visibilityOrSensitivity: adjustedVisibility, + categories: source.provider != BusyProvider.google + ? _draft.categories + : const [], + recurrence: adjustedRecurrence, + clearRecurrence: clearRecurrence, + clearImportance: source.provider != BusyProvider.microsoft, + clearColorId: _draft.accountId != source.accountId || providerChanged, + ); + final scope = updated.recurringMutationScope; + if (scope != null && !_supportsRecurringScopeFor(updated, source, scope)) { + updated = updated.copyWith(clearRecurringMutationScope: true); + } setState(() { if (source.provider == BusyProvider.google) { _addingCategory = false; } - _draft = _draft.copyWith( - accountId: source.accountId, - sourceId: source.id, - providerCalendarId: source.providerCalendarId, - categories: source.provider != BusyProvider.google - ? _draft.categories - : const [], - recurrence: adjustedRecurrence, - ); + _draft = updated; }); } + CalendarSourceEntity? _sourceForId(String sourceId) { + for (final source in _selectableSources) { + if (source.id == sourceId) return source; + } + return null; + } + + bool _isMovingTo(CalendarSourceEntity? destination) { + if (destination == null || widget.initialDraft.eventId == null) { + return false; + } + return destination.id != widget.initialDraft.sourceId || + destination.accountId != widget.initialDraft.accountId || + destination.providerCalendarId != + widget.initialDraft.providerCalendarId; + } + + bool _supportsRecurringScope( + CalendarSourceEntity destination, + RecurringEventMutationScope scope, + ) => _supportsRecurringScopeFor(_draft, destination, scope); + + bool _supportsRecurringScopeFor( + EventEditorDraft draft, + CalendarSourceEntity destination, + RecurringEventMutationScope scope, + ) { + if (!_isMovingTo(destination)) { + return scope != RecurringEventMutationScope.thisAndFuture || + supportsThisAndFollowingEventMutation(destination.provider); + } + if (scope == RecurringEventMutationScope.singleOccurrence) return true; + if (scope == RecurringEventMutationScope.thisAndFuture) return false; + final move = _eventMoveContext( + initialDraft: widget.initialDraft, + draft: draft.copyWith(recurringMutationScope: scope), + sources: _selectableSources, + ); + if (move == null || + move.strategy != CalendarEventMoveStrategy.copyThenDelete) { + return true; + } + final rule = EventRecurrenceCodec.decode( + destination.provider, + draft.recurrence, + baseDate: draft.start, + ); + return rule.isSupported && rule.repeats; + } + List _reminderRows(BusyProvider provider) { final l10n = context.l10n; final colorScheme = Theme.of(context).colorScheme; @@ -951,7 +1240,9 @@ class _EventEditorState extends State { BusyProvider.microsoft => source.allowedConferenceSolutions.contains( 'teamsForBusiness', ), - BusyProvider.appleICloud || BusyProvider.nextcloud => false, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => false, }; if (hasConference || canCreateConference) { rows.add( @@ -1186,9 +1477,17 @@ class _EventEditorState extends State { }); } - void _setAllDay(bool allDay) { + void _setAllDay(bool allDay, BusyProvider provider) { final start = _draft.start; final end = _draft.end; + final adjustedRecurrence = start == null + ? null + : _recurrenceForScheduleChange( + provider, + baseDate: start, + allDay: allDay, + timeZone: _draft.startTimeZone, + ); setState(() { _startTimeValid = true; _endTimeValid = true; @@ -1197,6 +1496,7 @@ class _EventEditorState extends State { end: start != null && !_isValidEventEnd(start, end, allDay) ? _defaultEndFor(start, allDay) : end, + recurrence: adjustedRecurrence, ); }); } @@ -1204,18 +1504,21 @@ class _EventEditorState extends State { bool get _hasUnsavedChanges { final hasInvalidVisibleTime = !_draft.allDay && (!_startTimeValid || !_endTimeValid); - return _draft != widget.initialDraft || hasInvalidVisibleTime; + final contentDraft = _draft.copyWith(clearRecurringMutationScope: true); + final initialContent = widget.initialDraft.copyWith( + clearRecurringMutationScope: true, + ); + return contentDraft != initialContent || hasInvalidVisibleTime; } void _setStart(DateTime start, BusyProvider provider) { final end = _draft.end; - final recurrenceType = _recurrenceType(_draft.recurrence); - final adjustedRecurrence = - provider == BusyProvider.microsoft && - _draft.recurrenceChanged && - recurrenceType != 'none' - ? _recurrenceFor(provider, recurrenceType, start) - : null; + final adjustedRecurrence = _recurrenceForScheduleChange( + provider, + baseDate: start, + allDay: _draft.allDay, + timeZone: _draft.startTimeZone, + ); setState(() { _draft = _draft.copyWith( start: start, @@ -1227,6 +1530,64 @@ class _EventEditorState extends State { }); } + void _setStartTimeZone(String timeZone, BusyProvider provider) { + final start = _draft.start; + final adjustedRecurrence = start == null + ? null + : _recurrenceForScheduleChange( + provider, + baseDate: start, + allDay: _draft.allDay, + timeZone: timeZone, + ); + setState(() { + _draft = _draft.copyWith( + startTimeZone: timeZone, + recurrence: adjustedRecurrence, + ); + }); + } + + Object? _recurrenceForScheduleChange( + BusyProvider provider, { + required DateTime baseDate, + required bool allDay, + required String? timeZone, + }) { + if (_draft.providerRecurringEventId != null || _draft.recurrence == null) { + return null; + } + var rule = EventRecurrenceCodec.decode( + provider, + _draft.recurrence, + baseDate: _draft.start, + ); + if (!rule.isSupported || !rule.repeats) return null; + final untilDate = rule.untilDateFor(timeZone: _draft.startTimeZone); + if (untilDate != null) { + rule = rule.withUntilDate( + untilDate, + allDay: allDay, + floating: _isFloatingDavTime( + provider, + allDay: allDay, + timeZone: timeZone, + ), + baseDate: baseDate, + timeZone: timeZone, + ); + } + if (!EventRecurrenceCodec.canEncode(provider, rule)) return null; + return EventRecurrenceCodec.encode( + provider, + rule, + baseDate: baseDate, + allDay: allDay, + timeZone: timeZone, + original: _draft.recurrence, + ); + } + void _setEnd(DateTime end) { final start = _draft.start; setState(() { @@ -1327,88 +1688,6 @@ DateTime _calendarDate(DateTime value) { return DateTime.utc(value.year, value.month, value.day); } -String _recurrenceType(Object? recurrence) { - if (recurrence is List && recurrence.isNotEmpty) { - final value = recurrence.first.toString().toUpperCase(); - if (value.contains('FREQ=DAILY')) return 'daily'; - if (value.contains('FREQ=WEEKLY')) return 'weekly'; - if (value.contains('FREQ=MONTHLY')) return 'monthly'; - if (value.contains('FREQ=YEARLY')) return 'yearly'; - } - if (recurrence is Map) { - final rules = recurrence['rules']; - if (rules is List && rules.isNotEmpty) { - final value = rules.first.toString().toUpperCase(); - if (value.contains('FREQ=DAILY')) return 'daily'; - if (value.contains('FREQ=WEEKLY')) return 'weekly'; - if (value.contains('FREQ=MONTHLY')) return 'monthly'; - if (value.contains('FREQ=YEARLY')) return 'yearly'; - } - final pattern = recurrence['pattern']; - if (pattern is Map) { - final type = pattern['type']?.toString(); - return switch (type) { - 'daily' => 'daily', - 'weekly' => 'weekly', - 'absoluteMonthly' => 'monthly', - 'absoluteYearly' => 'yearly', - _ => 'none', - }; - } - } - return 'none'; -} - -Object _recurrenceFor(BusyProvider provider, String type, DateTime? start) { - final freq = switch (type) { - 'daily' => 'DAILY', - 'weekly' => 'WEEKLY', - 'monthly' => 'MONTHLY', - 'yearly' => 'YEARLY', - _ => 'DAILY', - }; - if (provider != BusyProvider.microsoft) { - return ['RRULE:FREQ=$freq;INTERVAL=1']; - } - final recurrenceStart = start ?? DateTime.now(); - final patternType = switch (type) { - 'daily' => 'daily', - 'weekly' => 'weekly', - 'monthly' => 'absoluteMonthly', - 'yearly' => 'absoluteYearly', - _ => 'daily', - }; - return { - 'pattern': { - 'type': patternType, - 'interval': 1, - if (type == 'weekly') ...{ - 'daysOfWeek': [_microsoftDayOfWeek(recurrenceStart.weekday)], - 'firstDayOfWeek': 'sunday', - }, - if (type == 'monthly') 'dayOfMonth': recurrenceStart.day, - if (type == 'yearly') ...{ - 'dayOfMonth': recurrenceStart.day, - 'month': recurrenceStart.month, - }, - }, - 'range': {'type': 'noEnd', 'startDate': encodeDateOnly(recurrenceStart)}, - }; -} - -String _microsoftDayOfWeek(int weekday) { - return switch (weekday) { - DateTime.monday => 'monday', - DateTime.tuesday => 'tuesday', - DateTime.wednesday => 'wednesday', - DateTime.thursday => 'thursday', - DateTime.friday => 'friday', - DateTime.saturday => 'saturday', - DateTime.sunday => 'sunday', - _ => throw ArgumentError.value(weekday, 'weekday'), - }; -} - const _eventReminderMinuteOptions = [5, 10, 30, 60, 1440]; List _reminderMinutesList(Object? reminders) { @@ -1430,6 +1709,10 @@ List _reminderMinutesList(Object? reminders) { } return _normalizedReminderMinutes(values); } + final davMinutes = map['minutes']; + if (davMinutes is List) { + return _normalizedReminderMinutes(davMinutes.whereType()); + } return const []; } @@ -1457,6 +1740,35 @@ Object _disabledRemindersFor(BusyProvider provider) { return {'isReminderOn': false}; } +String _showAsForProvider(String? value, BusyProvider provider) { + if (provider == BusyProvider.microsoft) { + return switch (value) { + 'free' || 'tentative' || 'busy' || 'oof' || 'workingElsewhere' => value!, + 'transparent' => 'free', + _ => 'busy', + }; + } + return switch (value) { + 'opaque' || 'transparent' => value!, + 'free' => 'transparent', + _ => 'opaque', + }; +} + +String _visibilityForProvider(String? value, BusyProvider provider) { + if (provider == BusyProvider.microsoft) { + return switch (value) { + 'normal' || 'personal' || 'private' || 'confidential' => value!, + _ => 'normal', + }; + } + return switch (value) { + 'default' || 'public' || 'private' || 'confidential' => value!, + 'personal' => 'private', + _ => 'default', + }; +} + List _normalizedReminderMinutes(Iterable minutes) { final result = []; for (final value in minutes) { @@ -1525,6 +1837,17 @@ bool _looksLikeEmail(String value) { return RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(value); } +bool _isFloatingDavTime( + BusyProvider provider, { + required bool allDay, + required String? timeZone, +}) { + return !allDay && + (timeZone?.trim().isEmpty ?? true) && + (provider == BusyProvider.appleICloud || + provider == BusyProvider.nextcloud); +} + class _CalendarSourceDot extends StatelessWidget { const _CalendarSourceDot({required this.color}); diff --git a/lib/src/features/calendar/presentation/event_editor_draft.dart b/lib/src/features/calendar/presentation/event_editor_draft.dart index 7f0d870e..61937e4e 100644 --- a/lib/src/features/calendar/presentation/event_editor_draft.dart +++ b/lib/src/features/calendar/presentation/event_editor_draft.dart @@ -1,3 +1,4 @@ +import '../../../core/time/provider_date_time.dart'; import '../../../providers/busy_provider.dart'; import '../data/calendar_event_detail.dart'; @@ -7,6 +8,9 @@ enum RecurringEventMutationScope { thisAndFuture, } +bool supportsThisAndFollowingEventMutation(BusyProvider provider) => + provider != BusyProvider.microsoft; + class EventAttendeeDraft { const EventAttendeeDraft({ required this.email, @@ -103,7 +107,9 @@ class EventEditorDraft { required this.title, required this.allDay, this.eventId, + this.originalDetail, this.providerRecurringEventId, + this.eventType, this.recurringMutationScope, this.start, this.end, @@ -116,6 +122,7 @@ class EventEditorDraft { this.recurrence, this.recurrenceChanged = false, this.reminders, + this.remindersChanged = false, this.attendees = const [], this.attendeesChanged = false, this.importance, @@ -130,6 +137,7 @@ class EventEditorDraft { this.hideAttendees, this.allowNewTimeProposals, this.isOrganizer, + this.canManageAttendees = true, }); factory EventEditorDraft.newEvent({ @@ -164,7 +172,9 @@ class EventEditorDraft { final isOrganizer = switch (detail.provider) { BusyProvider.google => _jsonBool(organizer?['self']), BusyProvider.microsoft => _jsonBool(raw['isOrganizer']), - BusyProvider.appleICloud || BusyProvider.nextcloud => null, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, }; final hideAttendees = switch (detail.provider) { BusyProvider.google => @@ -172,26 +182,32 @@ class EventEditorDraft { ? !(raw['guestsCanSeeOtherGuests'] as bool) : null, BusyProvider.microsoft => _jsonBool(raw['hideAttendees']), - BusyProvider.appleICloud || BusyProvider.nextcloud => null, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, }; return EventEditorDraft.existing( eventId: detail.id, + originalDetail: detail, accountId: detail.accountId, sourceId: detail.sourceId, providerCalendarId: detail.providerCalendarId, providerRecurringEventId: detail.providerRecurringEventId, + eventType: detail.eventType, title: detail.title, allDay: detail.allDay, start: _eventEditorDateTime( allDay: detail.allDay, date: detail.startDate, dateTime: detail.startDateTime, + timeZone: detail.startTimeZone, ), end: _eventEditorDateTime( allDay: detail.allDay, date: detail.endDate, dateTime: detail.endDateTime, + timeZone: detail.endTimeZone, ), startTimeZone: detail.startTimeZone, endTimeZone: detail.endTimeZone, @@ -215,11 +231,16 @@ class EventEditorDraft { hideAttendees: hideAttendees, allowNewTimeProposals: _jsonBool(raw['allowNewTimeProposals']), isOrganizer: isOrganizer, + canManageAttendees: + detail.provider != BusyProvider.google || + isOrganizer == true || + detail.guestsCanInviteOthers, ); } factory EventEditorDraft.existing({ required String eventId, + CalendarEventDetail? originalDetail, required String accountId, required String sourceId, required String providerCalendarId, @@ -229,6 +250,7 @@ class EventEditorDraft { DateTime? end, String? location, String? providerRecurringEventId, + String? eventType, RecurringEventMutationScope? recurringMutationScope, String? description, String? descriptionContentType, @@ -249,13 +271,16 @@ class EventEditorDraft { bool? hideAttendees, bool? allowNewTimeProposals, bool? isOrganizer, + bool canManageAttendees = true, }) { return EventEditorDraft( eventId: eventId, + originalDetail: originalDetail, accountId: accountId, sourceId: sourceId, providerCalendarId: providerCalendarId, providerRecurringEventId: providerRecurringEventId, + eventType: eventType, recurringMutationScope: recurringMutationScope, title: title, allDay: allDay, @@ -281,11 +306,14 @@ class EventEditorDraft { hideAttendees: hideAttendees, allowNewTimeProposals: allowNewTimeProposals, isOrganizer: isOrganizer, + canManageAttendees: canManageAttendees, ); } final String? eventId; + final CalendarEventDetail? originalDetail; final String? providerRecurringEventId; + final String? eventType; final RecurringEventMutationScope? recurringMutationScope; final String accountId; final String sourceId; @@ -305,6 +333,9 @@ class EventEditorDraft { /// True only after the editor deliberately changes the hydrated value. final bool recurrenceChanged; final Object? reminders; + + /// True only after the editor deliberately changes the hydrated value. + final bool remindersChanged; final List attendees; /// True only after the editor deliberately changes the hydrated list. @@ -323,6 +354,7 @@ class EventEditorDraft { final bool? hideAttendees; final bool? allowNewTimeProposals; final bool? isOrganizer; + final bool canManageAttendees; bool get canSave { final start = this.start; @@ -355,6 +387,7 @@ class EventEditorDraft { Object? recurrence, bool? recurrenceChanged, Object? reminders, + bool? remindersChanged, List? attendees, bool? attendeesChanged, String? importance, @@ -369,6 +402,7 @@ class EventEditorDraft { bool? hideAttendees, bool? allowNewTimeProposals, bool? isOrganizer, + bool? canManageAttendees, RecurringEventMutationScope? recurringMutationScope, bool clearLocation = false, bool clearDescription = false, @@ -383,7 +417,9 @@ class EventEditorDraft { }) { return EventEditorDraft( eventId: eventId, + originalDetail: originalDetail, providerRecurringEventId: providerRecurringEventId, + eventType: eventType, recurringMutationScope: clearRecurringMutationScope ? null : recurringMutationScope ?? this.recurringMutationScope, @@ -409,6 +445,9 @@ class EventEditorDraft { recurrenceChanged ?? (this.recurrenceChanged || recurrence != null || clearRecurrence), reminders: clearReminders ? null : reminders ?? this.reminders, + remindersChanged: + remindersChanged ?? + (this.remindersChanged || reminders != null || clearReminders), attendees: attendees ?? this.attendees, attendeesChanged: attendeesChanged ?? (this.attendeesChanged || attendees != null), @@ -428,6 +467,7 @@ class EventEditorDraft { allowNewTimeProposals: allowNewTimeProposals ?? this.allowNewTimeProposals, isOrganizer: isOrganizer ?? this.isOrganizer, + canManageAttendees: canManageAttendees ?? this.canManageAttendees, ); } @@ -435,7 +475,9 @@ class EventEditorDraft { bool operator ==(Object other) { return other is EventEditorDraft && other.eventId == eventId && + other.originalDetail == originalDetail && other.providerRecurringEventId == providerRecurringEventId && + other.eventType == eventType && other.recurringMutationScope == recurringMutationScope && other.accountId == accountId && other.sourceId == sourceId && @@ -453,6 +495,7 @@ class EventEditorDraft { other.recurrence == recurrence && other.recurrenceChanged == recurrenceChanged && other.reminders == reminders && + other.remindersChanged == remindersChanged && _listEquals(other.attendees, attendees) && other.attendeesChanged == attendeesChanged && other.importance == importance && @@ -466,13 +509,16 @@ class EventEditorDraft { other.responseRequested == responseRequested && other.hideAttendees == hideAttendees && other.allowNewTimeProposals == allowNewTimeProposals && - other.isOrganizer == isOrganizer; + other.isOrganizer == isOrganizer && + other.canManageAttendees == canManageAttendees; } @override int get hashCode => Object.hashAll([ eventId, + originalDetail, providerRecurringEventId, + eventType, recurringMutationScope, accountId, sourceId, @@ -490,6 +536,7 @@ class EventEditorDraft { recurrence, recurrenceChanged, reminders, + remindersChanged, Object.hashAll(attendees), attendeesChanged, importance, @@ -504,6 +551,7 @@ class EventEditorDraft { hideAttendees, allowNewTimeProposals, isOrganizer, + canManageAttendees, ]); } @@ -526,25 +574,11 @@ DateTime? _eventEditorDateTime({ required bool allDay, required String? date, required String? dateTime, + required String? timeZone, }) { final value = allDay ? date ?? dateTime : dateTime; if (value == null || value.isEmpty) return null; - if (allDay) { - return DateTime.tryParse( - value.length >= 10 ? value.substring(0, 10) : value, - ); - } - final offsetWallTime = _parseOffsetWallDateTime(value); - if (offsetWallTime != null) return offsetWallTime; - final parsed = DateTime.tryParse(value); - if (parsed == null) return null; - return parsed.isUtc ? parsed.toLocal() : parsed; -} - -DateTime? _parseOffsetWallDateTime(String value) { - if (!RegExp(r'[+-]\d{2}:?\d{2}$').hasMatch(value)) return null; - final wallTime = value.replaceFirst(RegExp(r'[+-]\d{2}:?\d{2}$'), ''); - return DateTime.tryParse(wallTime); + return providerDateTimeAsWallTime(value, timeZone); } Map _jsonMap(Object? value) { diff --git a/lib/src/features/calendar/presentation/ical_import_flow.dart b/lib/src/features/calendar/presentation/ical_import_flow.dart new file mode 100644 index 00000000..0bf2f428 --- /dev/null +++ b/lib/src/features/calendar/presentation/ical_import_flow.dart @@ -0,0 +1,183 @@ +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../app/app_bootstrap.dart'; +import '../../../app/busymax_design.dart'; +import '../../../app/busymax_dialogs.dart'; +import '../../../ical/ical_import_service.dart'; +import '../../../ical/ical_ingestion.dart'; +import '../../../l10n/l10n.dart'; +import '../../../providers/busy_provider.dart'; +import '../../../platform/linux_header_bar_provider.dart'; +import '../data/calendar_repository.dart'; + +Future showIcsImportFlow( + BuildContext context, + WidgetRef ref, { + String? filePath, +}) async { + const typeGroup = XTypeGroup( + label: 'iCalendar', + extensions: ['ics'], + mimeTypes: ['text/calendar'], + ); + final file = filePath == null + ? await openFile(acceptedTypeGroups: const [typeGroup]) + : XFile(filePath); + if (file == null || !context.mounted) return; + try { + final length = await file.length(); + if (length > icalIngestionDecodedBodyLimit) { + throw const IcalIngestionException( + 'IcalBodyTooLarge', + 'The calendar data exceeds the 16 MiB limit.', + ); + } + final service = ref.read(icalImportServiceProvider); + final preview = service.parsePreview(await file.readAsBytes()); + final destinations = await service.writableDestinations(); + final accountLabels = { + for (final account + in await ref.read(accountsRepositoryProvider).watchAccounts().first) + account.id: account.selectorLabel, + }; + if (!context.mounted) return; + final destination = await showBusyMaxModalDialog( + context, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + barrierDismissible: false, + builder: (dialogContext) => _IcalImportPreviewDialog( + preview: preview, + destinations: destinations, + accountLabels: accountLabels, + ), + ); + if (destination == null || !context.mounted) return; + final report = await service.importPreview( + preview: preview, + destination: destination, + ); + if (!context.mounted) return; + await showBusyMaxModalDialog( + context, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + builder: (dialogContext) => _IcalImportReportDialog(report: report), + ); + } on Object catch (error) { + if (!context.mounted) return; + final code = switch (error) { + IcalIngestionException(:final code) => code, + _ => 'IcalImportFailed', + }; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.importIcsFailed(code)))); + } +} + +class _IcalImportPreviewDialog extends StatefulWidget { + const _IcalImportPreviewDialog({ + required this.preview, + required this.destinations, + required this.accountLabels, + }); + + final IcalImportPreview preview; + final List destinations; + final Map accountLabels; + + @override + State<_IcalImportPreviewDialog> createState() => + _IcalImportPreviewDialogState(); +} + +class _IcalImportPreviewDialogState extends State<_IcalImportPreviewDialog> { + CalendarSourceEntity? _destination; + + @override + void initState() { + super.initState(); + _destination = widget.destinations.firstOrNull; + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final omitted = widget.preview.fieldsThatWillBeOmitted.toList()..sort(); + return BusyMaxDialogShell( + title: l10n.importIcsPreview, + maxWidth: BusyMaxSizes.compactDetailsWidth, + actions: [ + BusyMaxPushButton.standard( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + BusyMaxPushButton.suggested( + key: const ValueKey('confirm-ics-import'), + onPressed: _destination == null + ? null + : () => Navigator.of(context).pop(_destination), + child: Text(l10n.importIcsConfirm), + ), + ], + children: [ + Text(l10n.importEventsFound(widget.preview.eventCount)), + if (widget.preview.invalidEventCount > 0) + Text(l10n.importInvalidEvents(widget.preview.invalidEventCount)), + if (omitted.isNotEmpty) + Text(l10n.importFieldsOmitted(omitted.join(', '))), + const SizedBox(height: BusyMaxSpacing.md), + if (widget.destinations.isEmpty) + Text(l10n.noWritableCalendars) + else + BusyMaxComboRow( + key: const ValueKey('ics-import-destination'), + title: l10n.importDestinationCalendar, + values: widget.destinations, + selected: _destination!, + labelFor: (source) => + '${widget.accountLabels[source.accountId] ?? source.provider.displayName} · ${source.summary}', + onSelected: (value) => setState(() => _destination = value), + ), + ], + ); + } +} + +class _IcalImportReportDialog extends StatelessWidget { + const _IcalImportReportDialog({required this.report}); + + final IcalImportReport report; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final omitted = report.fieldsIntentionallyOmitted.toList()..sort(); + final unsupported = report.unsupportedRecurrenceSets + .map((item) => '${item.uid}: ${item.reason}') + .toList(growable: false); + return BusyMaxDialogShell( + title: l10n.importIcsComplete, + actions: [ + BusyMaxPushButton.suggested( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.close), + ), + ], + children: [ + Text( + [ + l10n.importQueued(report.queued), + l10n.importDuplicatesSkipped(report.duplicatesSkipped), + l10n.importUnsupportedSets(report.unsupportedRecurrenceSets.length), + ...unsupported, + l10n.importInvalidEvents(report.invalidEvents), + if (omitted.isNotEmpty) + l10n.importFieldsOmitted(omitted.join(', ')), + ].join('\n'), + ), + ], + ); + } +} diff --git a/lib/src/features/connectivity/network_connectivity_service.dart b/lib/src/features/connectivity/network_connectivity_service.dart index f32dcbb2..e2fc010b 100644 --- a/lib/src/features/connectivity/network_connectivity_service.dart +++ b/lib/src/features/connectivity/network_connectivity_service.dart @@ -30,6 +30,12 @@ final class NetworkConnectivityMonitor { connectivityChanges ?? platform.onConnectivityChanged; } + /// Keeps network requests enabled without querying a platform status API. + NetworkConnectivityMonitor.withoutPlatformObservation() + : initialCheckTimeout = const Duration(seconds: 5), + _checkConnectivity = _unknownConnectivityCheck, + _connectivityChanges = const Stream>.empty(); + final Duration initialCheckTimeout; late final ConnectivityCheck _checkConnectivity; late final Stream> _connectivityChanges; @@ -186,6 +192,8 @@ final class NetworkConnectivityMonitor { } } +Future> _unknownConnectivityCheck() async => const []; + final class ConnectivityAwareHttpClient extends http.BaseClient { ConnectivityAwareHttpClient({ required http.Client inner, diff --git a/lib/src/features/feedback/data/feedback_submission.dart b/lib/src/features/feedback/data/feedback_submission.dart index 16bc3d4f..6c2c85ab 100644 --- a/lib/src/features/feedback/data/feedback_submission.dart +++ b/lib/src/features/feedback/data/feedback_submission.dart @@ -31,12 +31,11 @@ class FeedbackSubmission { required this.subject, required this.message, required this.replyEmail, + required this.platform, this.technicalDetails, }); static const applicationId = 'busymax'; - static const platform = 'linux'; - final String submissionId; final String appVersion; final String buildNumber; @@ -44,6 +43,7 @@ class FeedbackSubmission { final String subject; final String message; final String? replyEmail; + final String platform; final FeedbackTechnicalDetails? technicalDetails; Map toJson() => { diff --git a/lib/src/features/feedback/presentation/feedback_dialog.dart b/lib/src/features/feedback/presentation/feedback_dialog.dart index b383485b..fdda7c68 100644 --- a/lib/src/features/feedback/presentation/feedback_dialog.dart +++ b/lib/src/features/feedback/presentation/feedback_dialog.dart @@ -324,6 +324,7 @@ class _BusyMaxFeedbackDialogState extends State { subject: subject, message: message, replyEmail: replyEmail, + platform: Platform.operatingSystem, technicalDetails: includeTechnicalDetails ? FeedbackTechnicalDetails( osVersion: diff --git a/lib/src/features/notifications/desktop_notification_backend.dart b/lib/src/features/notifications/desktop_notification_backend.dart new file mode 100644 index 00000000..a02cab0e --- /dev/null +++ b/lib/src/features/notifications/desktop_notification_backend.dart @@ -0,0 +1,93 @@ +import 'package:flutter/foundation.dart'; + +enum DesktopNotificationReadinessState { + initializing, + available, + unavailableUnpackaged, + failedInstalledPackage, +} + +@immutable +final class DesktopNotificationReadiness { + const DesktopNotificationReadiness(this.state, {this.diagnosticCode}); + + const DesktopNotificationReadiness.initializing() + : this(DesktopNotificationReadinessState.initializing); + + final DesktopNotificationReadinessState state; + final String? diagnosticCode; + + bool get canNotify => state == DesktopNotificationReadinessState.available; +} + +typedef DesktopNotificationActionHandler = + Future Function(String action, Map? payload); + +enum BusyMaxNotificationCategory { + general, + reminder, + synchronization, + conflict, + networkError, +} + +@immutable +final class BusyMaxNotificationAction { + const BusyMaxNotificationAction(this.id, this.label); + + final String id; + final String label; +} + +@immutable +final class BusyMaxNotificationRequest { + const BusyMaxNotificationRequest({ + required this.stableId, + required this.title, + this.body = '', + this.category = BusyMaxNotificationCategory.general, + this.payload, + this.actions = const [], + this.transient = true, + this.private = false, + }); + + final String stableId; + final String title; + final String body; + final BusyMaxNotificationCategory category; + final Map? payload; + final List actions; + final bool transient; + final bool private; +} + +abstract interface class DesktopNotificationBackend { + Future notify( + BusyMaxNotificationRequest request, { + DesktopNotificationActionHandler? onAction, + }); + + Future cancel(String stableId); + + Future close(); +} + +final class DisabledDesktopNotificationBackend + implements DesktopNotificationBackend { + const DisabledDesktopNotificationBackend(); + + @override + Future cancel(String stableId) async {} + + @override + Future close() async {} + + @override + Future notify( + BusyMaxNotificationRequest request, { + DesktopNotificationActionHandler? onAction, + }) async { + throw UnsupportedError('Desktop notifications are unavailable.'); + } +} diff --git a/lib/src/features/notifications/desktop_notification_service.dart b/lib/src/features/notifications/desktop_notification_service.dart index a0c27dfc..cad42446 100644 --- a/lib/src/features/notifications/desktop_notification_service.dart +++ b/lib/src/features/notifications/desktop_notification_service.dart @@ -1,15 +1,14 @@ import 'dart:async'; import 'dart:ui'; -import 'package:desktop_notifications/desktop_notifications.dart'; - import '../../../l10n/generated/app_localizations.dart'; import '../../app/app_settings.dart'; import '../../core/logging/redacting_logger.dart'; import '../../l10n/locale_resolution.dart'; import '../sync/sync_failure_notification_policy.dart'; +import 'desktop_notification_backend.dart'; -typedef DesktopNotificationActionHandler = Future Function(String action); +export 'desktop_notification_backend.dart'; enum ReminderNotificationAction { open, snooze, dismiss } @@ -37,53 +36,6 @@ final class ReminderDeliveryResult { typedef ReminderNotificationActionHandler = Future Function(ReminderNotificationAction action); -abstract class DesktopNotificationBackend { - Future notify( - String summary, { - String body = '', - List hints = const [], - List actions = const [], - DesktopNotificationActionHandler? onAction, - }); - - Future close(); -} - -class FreedesktopNotificationBackend implements DesktopNotificationBackend { - FreedesktopNotificationBackend({NotificationsClient? client}) - : _client = client ?? NotificationsClient(); - - final NotificationsClient _client; - - @override - Future notify( - String summary, { - String body = '', - List hints = const [], - List actions = const [], - DesktopNotificationActionHandler? onAction, - }) async { - final notification = await _client.notify( - summary, - appName: 'BusyMax', - appIcon: 'io.busystack.busymax', - body: body, - hints: hints, - actions: actions, - ); - if (onAction != null) { - unawaited( - notification.action - .then(onAction) - .catchError((Object _) => Future.value()), - ); - } - } - - @override - Future close() => _client.close(); -} - class DesktopNotificationService { DesktopNotificationService({ required DesktopNotificationBackend backend, @@ -141,7 +93,8 @@ class DesktopNotificationService { await _safeNotify( _strings.syncFailureTitle, body, - NotificationCategory.networkError(), + BusyMaxNotificationCategory.networkError, + stableId: 'sync-failure', ); } @@ -155,7 +108,8 @@ class DesktopNotificationService { await _safeNotify( _strings.conflictTitle, body, - NotificationCategory.deviceError(), + BusyMaxNotificationCategory.conflict, + stableId: 'conflict', ); } @@ -166,13 +120,16 @@ class DesktopNotificationService { await _safeNotify( _strings.dueTodayTitle, _strings.dueTodayBody(count), - NotificationCategory.device(), + BusyMaxNotificationCategory.reminder, + stableId: 'due-today', ); } Future notifyEventReminder( String title, String? body, { + String stableId = 'event-reminder', + Map? payload, Future Function()? onActivated, ReminderNotificationActionHandler? onAction, }) async { @@ -189,7 +146,10 @@ class DesktopNotificationService { private ? _strings.detailsHidden : _nonEmpty(redactForLog(body ?? ''), _strings.eventReminderBody), - NotificationCategory.device(), + BusyMaxNotificationCategory.reminder, + stableId: stableId, + payload: payload, + private: private, onActivated: onActivated, onReminderAction: onAction, transient: false, @@ -204,6 +164,8 @@ class DesktopNotificationService { Future notifyTaskReminder( String title, String? body, { + String stableId = 'task-reminder', + Map? payload, Future Function()? onActivated, ReminderNotificationActionHandler? onAction, }) async { @@ -220,7 +182,10 @@ class DesktopNotificationService { private ? _strings.detailsHidden : _nonEmpty(redactForLog(body ?? ''), _strings.taskReminderBody), - NotificationCategory.device(), + BusyMaxNotificationCategory.reminder, + stableId: stableId, + payload: payload, + private: private, onActivated: onActivated, onReminderAction: onAction, transient: false, @@ -240,7 +205,10 @@ class DesktopNotificationService { Future _safeNotify( String summary, String body, - NotificationCategory category, { + BusyMaxNotificationCategory category, { + required String stableId, + Map? payload, + bool private = false, Future Function()? onActivated, ReminderNotificationActionHandler? onReminderAction, bool transient = true, @@ -248,20 +216,30 @@ class DesktopNotificationService { try { final hasActions = onActivated != null || onReminderAction != null; await _backend.notify( - summary, - body: body, - actions: !hasActions - ? const [] - : [ - NotificationAction('default', _strings.openAction), - if (onReminderAction != null) - NotificationAction('snooze', _strings.snoozeAction), - if (onReminderAction != null) - NotificationAction('dismiss', _strings.dismissAction), - ], + BusyMaxNotificationRequest( + stableId: stableId, + title: summary, + body: body, + category: category, + payload: payload, + transient: transient, + private: private, + actions: !hasActions + ? const [] + : [ + BusyMaxNotificationAction('default', _strings.openAction), + if (onReminderAction != null) + BusyMaxNotificationAction('snooze', _strings.snoozeAction), + if (onReminderAction != null) + BusyMaxNotificationAction( + 'dismiss', + _strings.dismissAction, + ), + ], + ), onAction: !hasActions ? null - : (action) async { + : (action, _) async { switch (action) { case 'default': if (onReminderAction != null) { @@ -279,14 +257,11 @@ class DesktopNotificationService { ); } }, - hints: [ - NotificationHint.category(category), - if (transient) NotificationHint.transient(), - ], ); return true; } on Object { - // DBus notifications may be unavailable in tests or headless sessions. + // A desktop notification backend may be unavailable in tests or a + // headless session. return false; } } @@ -372,7 +347,7 @@ class NotificationStrings { factory NotificationStrings.forLocales(List? locales) { final supportedLocale = resolveBusyMaxLocales( locales, - AppLocalizations.supportedLocales, + busyMaxSupportedLocales, ); return NotificationStrings.fromLocalizations( lookupAppLocalizations(supportedLocale), diff --git a/lib/src/features/notifications/notification_schedule_service.dart b/lib/src/features/notifications/notification_schedule_service.dart index 5da9ec34..18e48db2 100644 --- a/lib/src/features/notifications/notification_schedule_service.dart +++ b/lib/src/features/notifications/notification_schedule_service.dart @@ -95,6 +95,18 @@ class NotificationScheduleService { final isDav = provider == BusyProvider.appleICloud || provider == BusyProvider.nextcloud; + final reminderEnabledTaskListIds = { + for (final list + in await (_database.select(_database.taskLists)..where( + (row) => + row.accountId.equals(accountId) & + row.remindersEnabled.equals(true) & + row.pendingDelete.equals(false) & + row.serverMissing.equals(false), + )) + .get()) + list.id, + }; final rows = await (_database.select(_database.tasks)..where( (row) => @@ -106,6 +118,9 @@ class NotificationScheduleService { )) .get(); for (final task in rows) { + if (!reminderEnabledTaskListIds.contains(task.taskListId)) { + continue; + } if (task.status == 'completed') { continue; } @@ -456,7 +471,8 @@ List<_EventReminder> _eventReminders( ], startUtc: startUtc); } if ((provider == BusyProvider.appleICloud || - provider == BusyProvider.nextcloud) && + provider == BusyProvider.nextcloud || + provider == BusyProvider.webCal) && decoded is Map) { return _davEventReminders( decoded.cast(), diff --git a/lib/src/features/notifications/notification_scheduler.dart b/lib/src/features/notifications/notification_scheduler.dart index 720dc217..44529ab7 100644 --- a/lib/src/features/notifications/notification_scheduler.dart +++ b/lib/src/features/notifications/notification_scheduler.dart @@ -123,12 +123,16 @@ class NotificationScheduler { result = await _notifications.notifyEventReminder( row.title, row.body, + stableId: row.id, + payload: _activationPayload(row), onAction: (action) => _handleReminderAction(row, action), ); } else if (row.sourceType == 'task') { result = await _notifications.notifyTaskReminder( row.title, row.body, + stableId: row.id, + payload: _activationPayload(row), onAction: (action) => _handleReminderAction(row, action), ); } else { @@ -151,6 +155,13 @@ class NotificationScheduler { } } + Map _activationPayload(NotificationScheduleData row) => { + 'notificationScheduleId': row.id, + 'itemKind': row.sourceType, + 'accountId': row.accountId, + 'itemId': row.sourceId, + }; + Future _scheduleNextDueCheck() async { _dueTimer?.cancel(); _dueTimer = null; @@ -258,6 +269,38 @@ class NotificationScheduler { await checkNow(); } + /// Routes a process-independent desktop activation through the same action + /// path used for an in-process notification callback. + Future handleActivation({ + required String notificationScheduleId, + required String action, + }) async { + final row = + await (_database.select(_database.notificationSchedule) + ..where((table) => table.id.equals(notificationScheduleId))) + .getSingleOrNull(); + if (row == null) return; + final parsedAction = switch (action) { + 'default' || 'open' => ReminderNotificationAction.open, + 'snooze' => ReminderNotificationAction.snooze, + 'dismiss' => ReminderNotificationAction.dismiss, + _ => null, + }; + if (parsedAction == null) return; + + // Snooze and dismiss are safe under duplicate Windows toast delivery. + if (parsedAction == ReminderNotificationAction.dismiss && + row.dismissedAtUtc != null) { + return; + } + if (parsedAction == ReminderNotificationAction.snooze && + row.snoozedUntilUtc != null && + row.snoozedUntilUtc! > _nowUtc().millisecondsSinceEpoch) { + return; + } + await _handleReminderAction(row, parsedAction); + } + Future> _signedInAccountIds() async { final accounts = await (_database.select( _database.accounts, diff --git a/lib/src/features/recurrence/domain/event_recurrence_codec.dart b/lib/src/features/recurrence/domain/event_recurrence_codec.dart new file mode 100644 index 00000000..f5b3387f --- /dev/null +++ b/lib/src/features/recurrence/domain/event_recurrence_codec.dart @@ -0,0 +1,437 @@ +import '../../../providers/busy_provider.dart'; +import 'recurrence_rule.dart'; + +final class RecurrenceRuleLimits { + const RecurrenceRuleLimits({ + required this.ordinalPositions, + required this.allowMultipleMonthDays, + required this.allowMultipleMonths, + }); + + static const rfc5545 = RecurrenceRuleLimits( + ordinalPositions: {1, 2, 3, 4, 5, -2, -1}, + allowMultipleMonthDays: true, + allowMultipleMonths: true, + ); + + static const microsoftGraph = RecurrenceRuleLimits( + ordinalPositions: {1, 2, 3, 4, -1}, + allowMultipleMonthDays: false, + allowMultipleMonths: false, + ); + + final Set ordinalPositions; + final bool allowMultipleMonthDays; + final bool allowMultipleMonths; + + bool supports(RecurrenceRule rule) { + if (!rule.isSupported || rule.interval < 1 || rule.interval > 366) { + return false; + } + if (!rule.repeats) return true; + if (rule.count case final count? when count < 1 || count > 3500) { + return false; + } + if (rule.count != null && rule.untilRaw != null) return false; + if (!rfcWeekdays.contains(rule.weekStart) || + rule.byDay.toSet().length != rule.byDay.length || + rule.byMonth.toSet().length != rule.byMonth.length || + rule.byMonthDay.toSet().length != rule.byMonthDay.length || + rule.byMonth.any((value) => value < 1 || value > 12) || + rule.byMonthDay.any((value) => value < 1 || value > 31)) { + return false; + } + switch (rule.frequency) { + case RecurrenceFrequency.none: + return true; + case RecurrenceFrequency.daily: + return rule.byDay.isEmpty && + rule.byMonth.isEmpty && + rule.byMonthDay.isEmpty && + rule.bySetPosition == null; + case RecurrenceFrequency.weekly: + return rule.byDay.isNotEmpty && + rule.byDay.every(rfcWeekdays.contains) && + rule.byMonth.isEmpty && + rule.byMonthDay.isEmpty && + rule.bySetPosition == null; + case RecurrenceFrequency.monthly: + return rule.byMonth.isEmpty && _supportsMonthlyShape(rule); + case RecurrenceFrequency.yearly: + return rule.byMonth.isNotEmpty && + (allowMultipleMonths || rule.byMonth.length == 1) && + (rule.bySetPosition == null || rule.byMonth.length == 1) && + _supportsMonthlyShape(rule); + } + } + + bool _supportsMonthlyShape(RecurrenceRule rule) { + if (rule.byMonthDay.isNotEmpty) { + return rule.byDay.isEmpty && + rule.bySetPosition == null && + (allowMultipleMonthDays || rule.byMonthDay.length == 1); + } + final position = rule.bySetPosition; + return position != null && + ordinalPositions.contains(position) && + rule.byDay.isNotEmpty && + _isEditableOrdinalDaySet(rule.byDay); + } +} + +abstract final class EventRecurrenceCodec { + static RecurrenceRuleLimits limitsFor(BusyProvider provider) { + return provider == BusyProvider.microsoft + ? RecurrenceRuleLimits.microsoftGraph + : RecurrenceRuleLimits.rfc5545; + } + + static RecurrenceRule decode( + BusyProvider provider, + Object? value, { + DateTime? baseDate, + }) { + return switch (provider) { + BusyProvider.microsoft => _decodeMicrosoft(value, baseDate: baseDate), + BusyProvider.google || + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => _decodeIcalendar(value, baseDate: baseDate), + }; + } + + static bool canEncode(BusyProvider provider, RecurrenceRule rule) { + if (provider == BusyProvider.webCal) return false; + return limitsFor(provider).supports(rule); + } + + static Object? encode( + BusyProvider provider, + RecurrenceRule rule, { + required DateTime baseDate, + required bool allDay, + String? timeZone, + Object? original, + }) { + if (!canEncode(provider, rule)) { + throw StateError( + '${provider.displayName} cannot represent this recurrence rule.', + ); + } + if (!rule.repeats) return null; + return switch (provider) { + BusyProvider.google => [ + 'RRULE:${rule.toRrule()}', + ..._googleAncillaryLines(original), + ], + BusyProvider.appleICloud || BusyProvider.nextcloud => { + 'rules': [rule.toRrule()], + 'dates': const [], + 'excludedDates': const [], + }, + BusyProvider.microsoft => _encodeMicrosoft( + rule, + baseDate: baseDate, + timeZone: timeZone, + original: original, + ), + BusyProvider.webCal => throw StateError( + 'WebCal subscriptions are read-only.', + ), + }; + } +} + +RecurrenceRule _decodeIcalendar(Object? value, {DateTime? baseDate}) { + if (value == null) return const RecurrenceRule.none(); + final rules = []; + final dates = []; + final excludedDates = []; + if (value is List) { + for (final item in value) { + final line = item.toString().trim(); + final separator = line.indexOf(':'); + if (separator <= 0 || separator == line.length - 1) { + return RecurrenceRule.unsupported(rawRules: [line]); + } + final property = line.substring(0, separator).toUpperCase(); + final propertyName = property.split(';').first; + final rawValue = line.substring(separator + 1); + switch (propertyName) { + case 'RRULE': + rules.add(rawValue); + case 'RDATE': + dates.add(line); + case 'EXDATE': + excludedDates.add(line); + default: + return RecurrenceRule.unsupported(rawRules: [line]); + } + } + } else if (value is Map) { + try { + rules.addAll(_stringList(value['rules'])); + dates.addAll(_stringList(value['dates'])); + excludedDates.addAll(_stringList(value['excludedDates'])); + } on FormatException { + return const RecurrenceRule.unsupported(); + } + } else { + return const RecurrenceRule.unsupported(); + } + if (dates.isNotEmpty || excludedDates.isNotEmpty) { + return RecurrenceRule.unsupported( + rawRules: rules, + recurrenceDates: dates, + exceptionDates: excludedDates, + ); + } + return RecurrenceRule.fromIcalendar(rules: rules, baseDate: baseDate); +} + +RecurrenceRule _decodeMicrosoft(Object? value, {DateTime? baseDate}) { + if (value == null) return const RecurrenceRule.none(); + if (value is! Map) return const RecurrenceRule.unsupported(); + final patternValue = value['pattern']; + final rangeValue = value['range']; + if (patternValue is! Map || rangeValue is! Map) { + return const RecurrenceRule.unsupported(); + } + final pattern = patternValue.cast(); + final range = rangeValue.cast(); + final type = pattern['type']?.toString(); + final interval = _positiveInt(pattern['interval']); + if (interval == null) return const RecurrenceRule.unsupported(); + final graphDays = _stringListOrEmpty(pattern['daysOfWeek']); + final byDay = [ + for (final day in graphDays) + if (_graphDayToRfc(day) case final value?) value, + ]; + if (byDay.length != graphDays.length) { + return const RecurrenceRule.unsupported(); + } + final frequency = switch (type) { + 'daily' => RecurrenceFrequency.daily, + 'weekly' => RecurrenceFrequency.weekly, + 'absoluteMonthly' || 'relativeMonthly' => RecurrenceFrequency.monthly, + 'absoluteYearly' || 'relativeYearly' => RecurrenceFrequency.yearly, + _ => null, + }; + if (frequency == null) return const RecurrenceRule.unsupported(); + final relative = type == 'relativeMonthly' || type == 'relativeYearly'; + final absolute = type == 'absoluteMonthly' || type == 'absoluteYearly'; + final dayOfMonth = _positiveInt(pattern['dayOfMonth']); + final month = _positiveInt(pattern['month']); + final position = relative + ? _graphIndex(pattern['index']?.toString() ?? 'first') + : null; + final rangeType = range['type']?.toString(); + final startDate = _basicDateOrNull(range['startDate']?.toString()); + final count = rangeType == 'numbered' + ? _positiveInt(range['numberOfOccurrences']) + : null; + final endDate = rangeType == 'endDate' + ? _basicDateOrNull(range['endDate']?.toString()) + : null; + if (!const {'noEnd', 'numbered', 'endDate'}.contains(rangeType) || + startDate == null || + (rangeType == 'numbered' && count == null) || + (rangeType == 'endDate' && + (endDate == null || endDate.compareTo(startDate) < 0))) { + return const RecurrenceRule.unsupported(); + } + final weekStart = type == 'weekly' + ? _graphDayToRfc(pattern['firstDayOfWeek']?.toString() ?? 'sunday') + : 'MO'; + if (weekStart == null) return const RecurrenceRule.unsupported(); + final rule = RecurrenceRule( + frequency: frequency, + interval: interval, + byDay: type == 'weekly' || relative ? List.unmodifiable(byDay) : const [], + byMonth: type == 'absoluteYearly' || type == 'relativeYearly' + ? [if (month != null) month] + : const [], + byMonthDay: absolute ? [if (dayOfMonth != null) dayOfMonth] : const [], + bySetPosition: position, + weekStart: weekStart, + count: count, + untilRaw: endDate, + recurrenceDates: const [], + exceptionDates: const [], + rawRules: const [], + isSupported: true, + ); + if (!RecurrenceRuleLimits.microsoftGraph.supports(rule)) { + return const RecurrenceRule.unsupported(); + } + return rule; +} + +Map _encodeMicrosoft( + RecurrenceRule rule, { + required DateTime baseDate, + required String? timeZone, + required Object? original, +}) { + final relative = rule.bySetPosition != null; + final type = switch (rule.frequency) { + RecurrenceFrequency.daily => 'daily', + RecurrenceFrequency.weekly => 'weekly', + RecurrenceFrequency.monthly => + relative ? 'relativeMonthly' : 'absoluteMonthly', + RecurrenceFrequency.yearly => + relative ? 'relativeYearly' : 'absoluteYearly', + RecurrenceFrequency.none => throw StateError( + 'A non-repeating rule cannot be encoded.', + ), + }; + final pattern = { + 'type': type, + 'interval': rule.interval, + if (rule.frequency == RecurrenceFrequency.weekly) ...{ + 'daysOfWeek': rule.byDay.map(_rfcDayToGraph).toList(), + 'firstDayOfWeek': _rfcDayToGraph(rule.weekStart), + }, + if (rule.frequency == RecurrenceFrequency.monthly && !relative) + 'dayOfMonth': rule.byMonthDay.single, + if (rule.frequency == RecurrenceFrequency.yearly) ...{ + 'month': rule.byMonth.single, + if (!relative) 'dayOfMonth': rule.byMonthDay.single, + }, + if (relative) ...{ + 'daysOfWeek': rule.byDay.map(_rfcDayToGraph).toList(), + 'index': _rfcPositionToGraph(rule.bySetPosition!), + }, + }; + final endDate = rule.untilDateFor(timeZone: timeZone); + final recurrenceTimeZone = _microsoftRecurrenceTimeZone(original); + final range = { + 'type': rule.count != null + ? 'numbered' + : endDate != null + ? 'endDate' + : 'noEnd', + 'startDate': _isoDate(baseDate), + if (rule.count != null) 'numberOfOccurrences': rule.count, + if (rule.count == null && endDate != null) 'endDate': endDate, + if (recurrenceTimeZone != null) 'recurrenceTimeZone': recurrenceTimeZone, + }; + return {'pattern': pattern, 'range': range}; +} + +String? _microsoftRecurrenceTimeZone(Object? original) { + if (original is! Map) return null; + final range = original['range']; + if (range is! Map) return null; + final value = range['recurrenceTimeZone']?.toString().trim(); + return value == null || value.isEmpty ? null : value; +} + +List _googleAncillaryLines(Object? original) { + if (original is! List) return const []; + return [ + for (final item in original) + if (!item.toString().trimLeft().toUpperCase().startsWith('RRULE:')) + item.toString(), + ]; +} + +List _stringList(Object? value) { + if (value == null) return const []; + if (value is! List) throw const FormatException(); + return [for (final item in value) item.toString()]; +} + +List _stringListOrEmpty(Object? value) { + try { + return _stringList(value); + } on FormatException { + return const []; + } +} + +int? _positiveInt(Object? value) { + final parsed = value is int ? value : int.tryParse(value?.toString() ?? ''); + return parsed != null && parsed > 0 ? parsed : null; +} + +String? _basicDateOrNull(String? value) { + if (value == null || !RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(value)) { + return null; + } + final parsed = DateTime.tryParse(value); + if (parsed == null || _isoDate(parsed) != value) return null; + return '${parsed.year.toString().padLeft(4, '0')}' + '${parsed.month.toString().padLeft(2, '0')}' + '${parsed.day.toString().padLeft(2, '0')}'; +} + +String _isoDate(DateTime value) => + '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; + +String? _graphDayToRfc(String value) => switch (value.toLowerCase()) { + 'monday' => 'MO', + 'tuesday' => 'TU', + 'wednesday' => 'WE', + 'thursday' => 'TH', + 'friday' => 'FR', + 'saturday' => 'SA', + 'sunday' => 'SU', + _ => null, +}; + +String _rfcDayToGraph(String value) => switch (value) { + 'MO' => 'monday', + 'TU' => 'tuesday', + 'WE' => 'wednesday', + 'TH' => 'thursday', + 'FR' => 'friday', + 'SA' => 'saturday', + 'SU' => 'sunday', + _ => throw StateError('Invalid RFC weekday: $value'), +}; + +int? _graphIndex(String? value) => switch (value) { + 'first' => 1, + 'second' => 2, + 'third' => 3, + 'fourth' => 4, + 'last' => -1, + _ => null, +}; + +String _rfcPositionToGraph(int value) => switch (value) { + 1 => 'first', + 2 => 'second', + 3 => 'third', + 4 => 'fourth', + -1 => 'last', + _ => throw StateError('Invalid Microsoft recurrence position: $value'), +}; + +bool _isEditableOrdinalDaySet(List values) { + return ordinalDaySets.any((candidate) => _sameSet(values, candidate)); +} + +bool _sameSet(List left, List right) { + return left.length == right.length && + left.toSet().length == left.length && + left.toSet().containsAll(right); +} + +const rfcWeekdays = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']; + +const ordinalDaySets = >[ + ['MO'], + ['TU'], + ['WE'], + ['TH'], + ['FR'], + ['SA'], + ['SU'], + ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'], + ['MO', 'TU', 'WE', 'TH', 'FR'], + ['SA', 'SU'], +]; diff --git a/lib/src/features/recurrence/domain/recurrence_rule.dart b/lib/src/features/recurrence/domain/recurrence_rule.dart new file mode 100644 index 00000000..250558fb --- /dev/null +++ b/lib/src/features/recurrence/domain/recurrence_rule.dart @@ -0,0 +1,609 @@ +import 'dart:convert'; + +import '../../../core/time/provider_date_time.dart'; + +enum RecurrenceFrequency { none, daily, weekly, monthly, yearly } + +/// The recurrence subset BusyMax can edit without losing provider data. +/// +/// Rules outside that subset remain available through [rawRules] and are not +/// rewritten. The editable subset covers the RFC 5545 fields shared by the +/// supported calendar and task providers. +final class RecurrenceRule { + const RecurrenceRule({ + required this.frequency, + required this.interval, + required this.byDay, + required this.byMonth, + required this.byMonthDay, + required this.bySetPosition, + this.weekStart = 'MO', + required this.count, + required this.untilRaw, + required this.recurrenceDates, + required this.exceptionDates, + required this.rawRules, + required this.isSupported, + }); + + const RecurrenceRule.none() + : frequency = RecurrenceFrequency.none, + interval = 1, + byDay = const [], + byMonth = const [], + byMonthDay = const [], + bySetPosition = null, + weekStart = 'MO', + count = null, + untilRaw = null, + recurrenceDates = const [], + exceptionDates = const [], + rawRules = const [], + isSupported = true; + + factory RecurrenceRule.fromJson(String? source, {DateTime? baseDate}) { + if (source == null || source.isEmpty) { + return const RecurrenceRule.none(); + } + try { + final decoded = jsonDecode(source); + if (decoded is! Map) return _unsupported(const [], const [], const []); + final map = decoded.cast(); + final rules = _strings(map['rules']); + final dates = _strings(map['dates']); + final excludedDates = _strings(map['excludedDates']); + if (rules.isEmpty) { + return RecurrenceRule( + frequency: RecurrenceFrequency.none, + interval: 1, + byDay: const [], + byMonth: const [], + byMonthDay: const [], + bySetPosition: null, + weekStart: 'MO', + count: null, + untilRaw: null, + recurrenceDates: dates, + exceptionDates: excludedDates, + rawRules: rules, + isSupported: true, + ); + } + if (rules.length != 1) return _unsupported(rules, dates, excludedDates); + final parts = {}; + for (final segment in rules.single.split(';')) { + final separator = segment.indexOf('='); + if (separator <= 0 || separator == segment.length - 1) { + return _unsupported(rules, dates, excludedDates); + } + final key = segment.substring(0, separator).toUpperCase(); + if (parts.containsKey(key) || !_supportedParts.contains(key)) { + return _unsupported(rules, dates, excludedDates); + } + parts[key] = segment.substring(separator + 1).toUpperCase(); + } + final frequency = switch (parts['FREQ']) { + 'DAILY' => RecurrenceFrequency.daily, + 'WEEKLY' => RecurrenceFrequency.weekly, + 'MONTHLY' => RecurrenceFrequency.monthly, + 'YEARLY' => RecurrenceFrequency.yearly, + _ => null, + }; + final interval = int.tryParse(parts['INTERVAL'] ?? '1'); + final count = parts['COUNT'] == null + ? null + : int.tryParse(parts['COUNT']!); + final byDay = _csv(parts['BYDAY']); + final byMonth = _integers(parts['BYMONTH']); + final byMonthDay = _integers(parts['BYMONTHDAY']); + final bySetPositions = _integers(parts['BYSETPOS']); + final weekStart = parts['WKST'] ?? 'MO'; + final until = parts['UNTIL']; + final valid = + frequency != null && + interval != null && + interval >= 1 && + interval <= 366 && + (count == null || (count >= 1 && count <= 3500)) && + !(count != null && until != null) && + (until == null || _validUntil(until)) && + byMonth.every((value) => value >= 1 && value <= 12) && + byMonthDay.every( + (value) => value != 0 && value >= -31 && value <= 31, + ) && + bySetPositions.length <= 1 && + bySetPositions.every( + (value) => value != 0 && value >= -366 && value <= 366, + ) && + _plainWeekdays.contains(weekStart); + if (!valid) return _unsupported(rules, dates, excludedDates); + final supportedFrequency = frequency; + final supportedInterval = interval; + + final normalized = _normalizeEditorRule( + frequency: supportedFrequency, + byDay: byDay, + byMonth: byMonth, + byMonthDay: byMonthDay, + bySetPositions: bySetPositions, + baseDate: baseDate, + ); + if (normalized == null) { + return _unsupported(rules, dates, excludedDates); + } + return RecurrenceRule( + frequency: supportedFrequency, + interval: supportedInterval, + byDay: List.unmodifiable(normalized.byDay), + byMonth: List.unmodifiable(normalized.byMonth), + byMonthDay: List.unmodifiable(normalized.byMonthDay), + bySetPosition: normalized.bySetPosition, + weekStart: weekStart, + count: count, + untilRaw: until, + recurrenceDates: List.unmodifiable(dates), + exceptionDates: List.unmodifiable(excludedDates), + rawRules: List.unmodifiable(rules), + isSupported: true, + ); + } on Object { + return _unsupported(const [], const [], const []); + } + } + + factory RecurrenceRule.fromIcalendar({ + required List rules, + List recurrenceDates = const [], + List exceptionDates = const [], + DateTime? baseDate, + }) { + return RecurrenceRule.fromJson( + jsonEncode({ + 'rules': rules, + 'dates': recurrenceDates, + 'excludedDates': exceptionDates, + }), + baseDate: baseDate, + ); + } + + const RecurrenceRule.unsupported({ + this.rawRules = const [], + this.recurrenceDates = const [], + this.exceptionDates = const [], + }) : frequency = RecurrenceFrequency.none, + interval = 1, + byDay = const [], + byMonth = const [], + byMonthDay = const [], + bySetPosition = null, + weekStart = 'MO', + count = null, + untilRaw = null, + isSupported = false; + + final RecurrenceFrequency frequency; + final int interval; + final List byDay; + final List byMonth; + final List byMonthDay; + final int? bySetPosition; + final String weekStart; + final int? count; + final String? untilRaw; + final List recurrenceDates; + final List exceptionDates; + final List rawRules; + final bool isSupported; + + bool get repeats => frequency != RecurrenceFrequency.none; + + String? get untilDate => untilDateFor(); + + String? untilDateFor({String? timeZone}) { + final value = untilRaw; + if (value == null || value.length < 8) return null; + final parsed = _parseUntil(value); + if (parsed == null) return null; + final display = value.endsWith('Z') + ? providerUtcInstantAsWallTime(parsed, timeZone) + : parsed; + return '${display.year.toString().padLeft(4, '0')}-' + '${display.month.toString().padLeft(2, '0')}-' + '${display.day.toString().padLeft(2, '0')}'; + } + + RecurrenceRule copyWith({ + RecurrenceFrequency? frequency, + int? interval, + List? byDay, + List? byMonth, + List? byMonthDay, + Object? bySetPosition = _unchanged, + String? weekStart, + Object? count = _unchanged, + Object? untilRaw = _unchanged, + List? recurrenceDates, + List? exceptionDates, + }) => RecurrenceRule( + frequency: frequency ?? this.frequency, + interval: interval ?? this.interval, + byDay: List.unmodifiable(byDay ?? this.byDay), + byMonth: List.unmodifiable(byMonth ?? this.byMonth), + byMonthDay: List.unmodifiable(byMonthDay ?? this.byMonthDay), + bySetPosition: bySetPosition == _unchanged + ? this.bySetPosition + : bySetPosition as int?, + weekStart: weekStart ?? this.weekStart, + count: count == _unchanged ? this.count : count as int?, + untilRaw: untilRaw == _unchanged ? this.untilRaw : untilRaw as String?, + recurrenceDates: List.unmodifiable(recurrenceDates ?? this.recurrenceDates), + exceptionDates: List.unmodifiable(exceptionDates ?? this.exceptionDates), + rawRules: const [], + isSupported: true, + ); + + RecurrenceRule withUntilDate( + String? date, { + required bool allDay, + bool floating = false, + DateTime? baseDate, + String? timeZone, + }) { + if (date == null || date.isEmpty) return copyWith(untilRaw: null); + final parsed = DateTime.tryParse(date); + if (parsed == null) return this; + final wall = DateTime( + parsed.year, + parsed.month, + parsed.day, + baseDate?.hour ?? 0, + baseDate?.minute ?? 0, + baseDate?.second ?? 0, + ); + final basic = _basicDate(wall); + if (allDay) return copyWith(untilRaw: basic); + if (floating) { + return copyWith( + untilRaw: + '${basic}T' + '${wall.hour.toString().padLeft(2, '0')}' + '${wall.minute.toString().padLeft(2, '0')}' + '${wall.second.toString().padLeft(2, '0')}', + ); + } + final utc = + providerDateTimeAsUtcInstant(wall.toIso8601String(), timeZone) ?? + wall.toUtc(); + return copyWith( + untilRaw: + '${_basicDate(utc)}T' + '${utc.hour.toString().padLeft(2, '0')}' + '${utc.minute.toString().padLeft(2, '0')}' + '${utc.second.toString().padLeft(2, '0')}Z', + ); + } + + String toJsonString() => jsonEncode({ + 'rules': repeats ? [toRrule()] : const [], + 'dates': recurrenceDates, + 'excludedDates': exceptionDates, + }); + + String toRrule() { + if (!isSupported || !repeats) { + throw StateError('Only a supported repeating rule can be serialized.'); + } + final result = [ + 'FREQ=${frequency.name.toUpperCase()}', + 'INTERVAL=$interval', + if (byDay.isNotEmpty) 'BYDAY=${byDay.join(',')}', + if (byMonth.isNotEmpty) 'BYMONTH=${byMonth.join(',')}', + if (byMonthDay.isNotEmpty) 'BYMONTHDAY=${byMonthDay.join(',')}', + if (bySetPosition != null) 'BYSETPOS=$bySetPosition', + if (frequency == RecurrenceFrequency.weekly) 'WKST=$weekStart', + if (count != null) 'COUNT=$count', + if (untilRaw != null) 'UNTIL=$untilRaw', + ]; + return result.join(';'); + } + + @override + bool operator ==(Object other) { + return other is RecurrenceRule && + other.frequency == frequency && + other.interval == interval && + _listEquals(other.byDay, byDay) && + _listEquals(other.byMonth, byMonth) && + _listEquals(other.byMonthDay, byMonthDay) && + other.bySetPosition == bySetPosition && + other.weekStart == weekStart && + other.count == count && + other.untilRaw == untilRaw && + _listEquals(other.recurrenceDates, recurrenceDates) && + _listEquals(other.exceptionDates, exceptionDates) && + _listEquals(other.rawRules, rawRules) && + other.isSupported == isSupported; + } + + @override + int get hashCode => Object.hash( + frequency, + interval, + Object.hashAll(byDay), + Object.hashAll(byMonth), + Object.hashAll(byMonthDay), + bySetPosition, + weekStart, + count, + untilRaw, + Object.hashAll(recurrenceDates), + Object.hashAll(exceptionDates), + Object.hashAll(rawRules), + isSupported, + ); +} + +const _supportedParts = { + 'FREQ', + 'INTERVAL', + 'BYDAY', + 'BYMONTH', + 'BYMONTHDAY', + 'BYSETPOS', + 'WKST', + 'COUNT', + 'UNTIL', +}; + +const _unchanged = Object(); + +RecurrenceRule _unsupported( + List rules, + List dates, + List excludedDates, +) => RecurrenceRule( + frequency: RecurrenceFrequency.none, + interval: 1, + byDay: const [], + byMonth: const [], + byMonthDay: const [], + bySetPosition: null, + weekStart: 'MO', + count: null, + untilRaw: null, + recurrenceDates: List.unmodifiable(dates), + exceptionDates: List.unmodifiable(excludedDates), + rawRules: List.unmodifiable(rules), + isSupported: false, +); + +List _strings(Object? source) { + if (source == null) return const []; + if (source is! List) throw const FormatException(); + return [for (final value in source) value.toString()]; +} + +List _csv(String? source) => source == null || source.isEmpty + ? const [] + : source.split(',').where((value) => value.isNotEmpty).toList(); + +List _integers(String? source) { + final values = _csv(source); + final result = []; + for (final value in values) { + final parsed = int.tryParse(value); + if (parsed == null) throw const FormatException(); + result.add(parsed); + } + return result; +} + +typedef _NormalizedEditorRule = ({ + List byDay, + List byMonth, + List byMonthDay, + int? bySetPosition, +}); + +_NormalizedEditorRule? _normalizeEditorRule({ + required RecurrenceFrequency frequency, + required List byDay, + required List byMonth, + required List byMonthDay, + required List bySetPositions, + required DateTime? baseDate, +}) { + switch (frequency) { + case RecurrenceFrequency.none: + return null; + case RecurrenceFrequency.daily: + if (byDay.isNotEmpty || + byMonth.isNotEmpty || + byMonthDay.isNotEmpty || + bySetPositions.isNotEmpty) { + return null; + } + return ( + byDay: const [], + byMonth: const [], + byMonthDay: const [], + bySetPosition: null, + ); + case RecurrenceFrequency.weekly: + if (byMonth.isNotEmpty || + byMonthDay.isNotEmpty || + bySetPositions.isNotEmpty || + byDay.any((value) => !_plainWeekdays.contains(value))) { + return null; + } + return ( + byDay: byDay.isEmpty && baseDate != null + ? [_weekdayFor(baseDate.weekday)] + : byDay, + byMonth: const [], + byMonthDay: const [], + bySetPosition: null, + ); + case RecurrenceFrequency.monthly: + if (byMonth.isNotEmpty) return null; + return _normalizeMonthlyOrYearly( + byDay: byDay, + byMonth: const [], + byMonthDay: byMonthDay, + bySetPositions: bySetPositions, + defaultMonthDay: baseDate?.day, + ); + case RecurrenceFrequency.yearly: + final months = byMonth.isEmpty && baseDate != null + ? [baseDate.month] + : byMonth; + if (months.isEmpty) return null; + return _normalizeMonthlyOrYearly( + byDay: byDay, + byMonth: months, + byMonthDay: byMonthDay, + bySetPositions: bySetPositions, + defaultMonthDay: baseDate?.day, + ); + } +} + +_NormalizedEditorRule? _normalizeMonthlyOrYearly({ + required List byDay, + required List byMonth, + required List byMonthDay, + required List bySetPositions, + required int? defaultMonthDay, +}) { + if (byMonthDay.isNotEmpty) { + if (byDay.isNotEmpty || + bySetPositions.isNotEmpty || + byMonthDay.any((value) => value < 1 || value > 31)) { + return null; + } + return ( + byDay: const [], + byMonth: byMonth, + byMonthDay: byMonthDay, + bySetPosition: null, + ); + } + + if (byDay.length == 1 && bySetPositions.isEmpty) { + final ordinal = RegExp( + r'^(-?[1-5])(MO|TU|WE|TH|FR|SA|SU)$', + ).firstMatch(byDay.single); + if (ordinal != null) { + if (byMonth.length > 1) return null; + final position = int.parse(ordinal.group(1)!); + if (_editorSetPositions.contains(position)) { + return ( + byDay: [ordinal.group(2)!], + byMonth: byMonth, + byMonthDay: const [], + bySetPosition: position, + ); + } + } + } + + if (byDay.isNotEmpty && + bySetPositions.length == 1 && + _allowedEditorByDay(byDay) && + _editorSetPositions.contains(bySetPositions.single)) { + // A single weekday plus BYSETPOS is ordinal within the whole yearly + // candidate set when multiple BYMONTH values are present. BusyMax's + // editor models an ordinal inside each selected month, so keep that RFC + // shape opaque instead of silently changing its meaning. Multi-weekday + // workday/weekend sets remain representable for Nextcloud tasks. + if (byMonth.length > 1 && byDay.length == 1) return null; + return ( + byDay: byDay, + byMonth: byMonth, + byMonthDay: const [], + bySetPosition: bySetPositions.single, + ); + } + + if (byDay.isEmpty && bySetPositions.isEmpty && defaultMonthDay != null) { + return ( + byDay: const [], + byMonth: byMonth, + byMonthDay: [defaultMonthDay], + bySetPosition: null, + ); + } + return null; +} + +bool _allowedEditorByDay(List values) { + final sorted = [...values]..sort(); + return _editorDaySets.any((allowed) { + final candidate = [...allowed]..sort(); + if (candidate.length != sorted.length) return false; + for (var index = 0; index < sorted.length; index += 1) { + if (candidate[index] != sorted[index]) return false; + } + return true; + }); +} + +bool _validUntil(String value) => _parseUntil(value) != null; + +DateTime? _parseUntil(String value) { + final match = RegExp( + r'^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$', + ).firstMatch(value); + if (match == null) return null; + final year = int.parse(match.group(1)!); + final month = int.parse(match.group(2)!); + final day = int.parse(match.group(3)!); + final hasTime = match.group(4) != null; + final hour = hasTime ? int.parse(match.group(4)!) : 0; + final minute = hasTime ? int.parse(match.group(5)!) : 0; + final second = hasTime ? int.parse(match.group(6)!) : 0; + final parsed = match.group(7) == 'Z' + ? DateTime.utc(year, month, day, hour, minute, second) + : DateTime(year, month, day, hour, minute, second); + if (parsed.year != year || + parsed.month != month || + parsed.day != day || + parsed.hour != hour || + parsed.minute != minute || + parsed.second != second) { + return null; + } + return parsed; +} + +String _basicDate(DateTime value) => + '${value.year.toString().padLeft(4, '0')}' + '${value.month.toString().padLeft(2, '0')}' + '${value.day.toString().padLeft(2, '0')}'; + +String _weekdayFor(int weekday) => _plainWeekdays[(weekday - 1).clamp(0, 6)]; + +const _plainWeekdays = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']; +const _editorSetPositions = {1, 2, 3, 4, 5, -2, -1}; +const _editorDaySets = >[ + ['MO'], + ['TU'], + ['WE'], + ['TH'], + ['FR'], + ['SA'], + ['SU'], + ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'], + ['MO', 'TU', 'WE', 'TH', 'FR'], + ['SA', 'SU'], +]; + +bool _listEquals(List left, List right) { + if (identical(left, right)) return true; + if (left.length != right.length) return false; + for (var index = 0; index < left.length; index += 1) { + if (left[index] != right[index]) return false; + } + return true; +} diff --git a/lib/src/features/recurrence/presentation/recurrence_editor.dart b/lib/src/features/recurrence/presentation/recurrence_editor.dart new file mode 100644 index 00000000..024cf16f --- /dev/null +++ b/lib/src/features/recurrence/presentation/recurrence_editor.dart @@ -0,0 +1,899 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../app/busymax_design.dart'; +import '../../../app/busymax_dialogs.dart'; +import '../../../l10n/l10n.dart'; +import '../../../l10n/localized_formatters.dart'; +import '../../../platform/linux_header_bar_service.dart'; +import '../../tasks/presentation/desktop_date_time_fields.dart'; +import '../domain/event_recurrence_codec.dart'; +import '../domain/recurrence_rule.dart'; + +Future showRecurrenceEditorDialog( + BuildContext context, { + required RecurrenceRule initial, + required bool allDay, + required DateTime baseDate, + required DateTime minimumDate, + required bool useNativeDatePicker, + bool floating = false, + String? timeZone, + RecurrenceRuleLimits limits = RecurrenceRuleLimits.rfc5545, + Color? barrierColor, + LinuxHeaderBarService? headerBarService, +}) { + return showBusyMaxModalEditorDialog( + context, + barrierColor: barrierColor, + headerBarService: headerBarService, + maxWidth: 620, + maxHeight: 820, + builder: (context) => RecurrenceEditorDialog( + initial: initial, + allDay: allDay, + baseDate: baseDate, + minimumDate: minimumDate, + floating: floating, + timeZone: timeZone, + limits: limits, + useNativeDatePicker: useNativeDatePicker, + ), + ); +} + +String recurrenceRuleSummary( + BuildContext context, + RecurrenceRule recurrence, { + String? timeZone, +}) { + if (!recurrence.repeats) return context.l10n.repeatNone; + final l10n = context.l10n; + if (recurrence.frequency == RecurrenceFrequency.yearly && + recurrence.bySetPosition != null && + recurrence.byMonth.length > 1) { + return l10n.unsupportedRecurrencePreserved; + } + var summary = recurrence.interval == 1 + ? switch (recurrence.frequency) { + RecurrenceFrequency.daily => l10n.repeatDaily, + RecurrenceFrequency.weekly => l10n.repeatWeekly, + RecurrenceFrequency.monthly => l10n.repeatMonthly, + RecurrenceFrequency.yearly => l10n.repeatYearly, + RecurrenceFrequency.none => l10n.repeatNone, + } + : switch (recurrence.frequency) { + RecurrenceFrequency.daily => l10n.repeatEveryDays( + recurrence.interval, + ), + RecurrenceFrequency.weekly => l10n.repeatEveryWeeks( + recurrence.interval, + ), + RecurrenceFrequency.monthly => l10n.repeatEveryMonths( + recurrence.interval, + ), + RecurrenceFrequency.yearly => l10n.repeatEveryYears( + recurrence.interval, + ), + RecurrenceFrequency.none => l10n.repeatNone, + }; + var yearlyMonthPlacedInSummary = false; + if (recurrence.frequency == RecurrenceFrequency.weekly && + recurrence.byDay.isNotEmpty) { + final dayValues = recurrence.byDay + .map((day) => _localizedWeeklyDaySummary(context, day)) + .toList(growable: false); + final days = _localizedRecurrenceList( + dayValues, + pair: l10n.repeatWeekdayListPair, + start: l10n.repeatWeekdayListStart, + ); + summary = + '$summary${l10n.repeatSummarySeparator}${l10n.repeatOnDaysSummary(days)}'; + } else if ((recurrence.frequency == RecurrenceFrequency.monthly || + recurrence.frequency == RecurrenceFrequency.yearly) && + recurrence.byMonthDay.isNotEmpty) { + final monthDayValues = recurrence.byMonthDay + .map( + (day) => l10n.repeatMonthDayValue(_localizedDayNumber(context, day)), + ) + .toList(growable: false); + if (recurrence.frequency == RecurrenceFrequency.yearly && + recurrence.byMonth.isNotEmpty) { + final monthValues = recurrence.byMonth + .map((month) => _localizedYearlyMonth(context, month)) + .toList(growable: false); + final monthDays = _localizedRecurrenceList( + monthDayValues, + pair: l10n.repeatYearlyMonthDayListPair, + start: l10n.repeatYearlyMonthDayListStart, + ); + final months = _localizedRecurrenceList( + monthValues, + pair: l10n.repeatYearlyMonthListPair, + start: l10n.repeatYearlyMonthListStart, + ); + summary = switch ((monthValues.length, monthDayValues.length)) { + (1, 1) => l10n.repeatYearlyOnMonthDaySummary( + summary, + monthValues.single, + monthDayValues.single, + ), + (1, 2) => l10n.repeatYearlyOnTwoMonthDaysSummary( + summary, + monthValues.single, + monthDayValues.first, + monthDayValues.last, + ), + (1, _) => l10n.repeatYearlyOnMonthDaysSummary( + summary, + monthValues.single, + monthDays, + ), + (2, 1) => l10n.repeatYearlyInTwoMonthsOnMonthDaySummary( + summary, + monthValues.first, + monthValues.last, + monthDayValues.single, + ), + (2, 2) => l10n.repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + summary, + monthValues.first, + monthValues.last, + monthDayValues.first, + monthDayValues.last, + ), + (2, _) => l10n.repeatYearlyInTwoMonthsOnMonthDaysSummary( + summary, + monthValues.first, + monthValues.last, + monthDays, + ), + (_, 1) => l10n.repeatYearlyInMonthsOnMonthDaySummary( + summary, + months, + monthDayValues.single, + ), + _ => l10n.repeatYearlyInMonthsOnMonthDaysSummary( + summary, + months, + monthDays, + ), + }; + yearlyMonthPlacedInSummary = true; + } else { + final monthDays = _localizedRecurrenceList( + monthDayValues, + pair: l10n.repeatMonthDayListPair, + start: l10n.repeatMonthDayListStart, + ); + final monthDaysSummary = switch (monthDayValues.length) { + 1 => l10n.repeatOnMonthDaysSummary(monthDayValues.single), + 2 => l10n.repeatOnTwoMonthDaysSummary( + monthDayValues.first, + monthDayValues.last, + ), + _ => l10n.repeatOnMonthDaysSummaryMultiple(monthDays), + }; + summary = '$summary${l10n.repeatSummarySeparator}$monthDaysSummary'; + } + } else if (recurrence.bySetPosition case final position?) { + final days = _localizedOrdinalDaySummary(context, recurrence.byDay); + if (recurrence.frequency == RecurrenceFrequency.yearly && + recurrence.byMonth.isNotEmpty) { + final month = _localizedYearlyMonth(context, recurrence.byMonth.single); + summary = l10n.repeatYearlyOnOrdinalSummary( + summary, + month, + _ordinalPositionKey(position), + days, + ); + yearlyMonthPlacedInSummary = true; + } else { + final ordinalSummary = l10n.repeatOnOrdinalSummary( + _ordinalPositionKey(position), + days, + ); + summary = '$summary${l10n.repeatSummarySeparator}$ordinalSummary'; + } + } + if (recurrence.frequency == RecurrenceFrequency.yearly && + recurrence.byMonth.isNotEmpty && + !yearlyMonthPlacedInSummary) { + final monthValues = recurrence.byMonth + .map((month) => _localizedYearlyMonth(context, month)) + .toList(growable: false); + final months = _localizedRecurrenceList( + monthValues, + pair: l10n.repeatYearlyMonthListPair, + start: l10n.repeatYearlyMonthListStart, + ); + summary = '$summary ${l10n.repeatInMonthsSummary(months)}'; + } + if (recurrence.count != null) { + return '$summary · ${l10n.repeatTimesSummary(recurrence.count!)}'; + } + final until = recurrence.untilDateFor(timeZone: timeZone); + if (until == null) return summary; + final date = DateTime.tryParse(until); + final label = date == null + ? until + : MaterialLocalizations.of(context).formatMediumDate(date); + return '$summary · ${l10n.repeatUntilSummary(label)}'; +} + +class RecurrenceEditorDialog extends StatefulWidget { + const RecurrenceEditorDialog({ + super.key, + required this.initial, + required this.allDay, + required this.baseDate, + required this.minimumDate, + required this.floating, + required this.useNativeDatePicker, + required this.limits, + this.timeZone, + }); + + final RecurrenceRule initial; + final bool allDay; + final DateTime baseDate; + final DateTime minimumDate; + final bool floating; + final bool useNativeDatePicker; + final RecurrenceRuleLimits limits; + final String? timeZone; + + @override + State createState() => _RecurrenceEditorDialogState(); +} + +enum _RecurrenceEnd { never, until, count } + +class _RecurrenceEditorDialogState extends State { + late RecurrenceRule _value; + late _RecurrenceEnd _end; + late final TextEditingController _intervalController; + late final TextEditingController _countController; + + @override + void initState() { + super.initState(); + _value = widget.initial.isSupported + ? widget.initial + : const RecurrenceRule.none(); + _end = _value.count != null + ? _RecurrenceEnd.count + : _value.untilRaw != null + ? _RecurrenceEnd.until + : _RecurrenceEnd.never; + _intervalController = TextEditingController(text: '${_value.interval}'); + _countController = TextEditingController(text: '${_value.count ?? 10}'); + } + + @override + void dispose() { + _intervalController.dispose(); + _countController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final repeats = _value.repeats; + return BusyMaxModalEditorScaffold( + title: l10n.repeat, + cancelLabel: l10n.cancel, + saveLabel: l10n.save, + onCancel: () => Navigator.pop(context), + onSave: _isValid ? () => Navigator.pop(context, _value) : null, + contentMaxWidth: 560, + children: [ + BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxComboRow( + title: l10n.repeat, + values: RecurrenceFrequency.values, + selected: _value.frequency, + labelFor: _frequencyLabel, + onSelected: _setFrequency, + ), + if (repeats) + YaruListTile.square( + title: TextField( + controller: _intervalController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: l10n.repeatEvery, + ), + onChanged: (value) { + final parsed = int.tryParse(value); + setState(() { + if (parsed != null && parsed >= 1 && parsed <= 366) { + _value = _value.copyWith(interval: parsed); + } + }); + }, + ), + ), + ], + ), + if (_value.frequency == RecurrenceFrequency.weekly) + BusyMaxGroupedList( + title: l10n.repeatOn, + filled: true, + children: [ + Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.md), + child: YaruChoiceChipBar( + style: YaruChoiceChipBarStyle.wrap, + labels: [ + for (final day in rfcWeekdays) + Text( + _localizedStandaloneWeekday( + context, + day, + abbreviated: true, + ), + ), + ], + isSelected: [ + for (final day in rfcWeekdays) _value.byDay.contains(day), + ], + selectedFirst: false, + clearOnSelect: false, + onSelected: (index) { + final day = rfcWeekdays[index]; + _toggleWeekday(day, !_value.byDay.contains(day)); + }, + ), + ), + ], + ), + if (_value.frequency == RecurrenceFrequency.monthly || + _value.frequency == RecurrenceFrequency.yearly) + BusyMaxGroupedList( + title: l10n.repeatDayOfMonth, + filled: true, + children: [ + Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.md), + child: YaruChoiceChipBar( + style: YaruChoiceChipBarStyle.wrap, + labels: [ + for (var day = 1; day <= 31; day += 1) + Text(_localizedDayNumber(context, day)), + ], + isSelected: [ + for (var day = 1; day <= 31; day += 1) + _value.byMonthDay.contains(day), + ], + selectedFirst: false, + clearOnSelect: false, + onSelected: _value.bySetPosition == null + ? (index) { + final day = index + 1; + _toggleMonthDay( + day, + !_value.byMonthDay.contains(day), + ); + } + : null, + ), + ), + BusyMaxComboRow( + title: l10n.repeatOrdinal, + values: [ + 0, + for (final position in _ordinalPositions) + if (widget.limits.ordinalPositions.contains(position)) + position, + ], + selected: _value.bySetPosition ?? 0, + labelFor: (value) => value == 0 + ? l10n.repeatSpecificDays + : _localizedOrdinal(context, value), + onSelected: _setOrdinal, + ), + if (_value.bySetPosition != null) + BusyMaxComboRow( + title: l10n.repeatOn, + values: [for (final choice in _ordinalDayChoices) choice.key], + selected: _ordinalDayChoiceKey(_value.byDay), + labelFor: (value) => _localizedOrdinalDayChoice( + context, + _ordinalDayChoices + .firstWhere((choice) => choice.key == value) + .days, + ), + onSelected: (value) { + final days = _ordinalDayChoices + .firstWhere((choice) => choice.key == value) + .days; + setState(() => _value = _value.copyWith(byDay: days)); + }, + ), + ], + ), + if (_value.frequency == RecurrenceFrequency.yearly) + BusyMaxGroupedList( + title: l10n.repeatMonths, + filled: true, + children: [ + Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.md), + child: YaruChoiceChipBar( + style: YaruChoiceChipBarStyle.wrap, + labels: [ + for (var month = 1; month <= 12; month += 1) + Text(_localizedStandaloneMonth(context, month)), + ], + isSelected: [ + for (var month = 1; month <= 12; month += 1) + _value.byMonth.contains(month), + ], + selectedFirst: false, + clearOnSelect: false, + onSelected: (index) { + final month = index + 1; + _toggleMonth(month, !_value.byMonth.contains(month)); + }, + ), + ), + ], + ), + if (repeats) + BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxComboRow<_RecurrenceEnd>( + title: l10n.repeatEnd, + values: _RecurrenceEnd.values, + selected: _end, + labelFor: (value) => switch (value) { + _RecurrenceEnd.never => l10n.repeatNever, + _RecurrenceEnd.until => l10n.repeatUntil, + _RecurrenceEnd.count => l10n.repeatAfter, + }, + onSelected: _setEnd, + ), + if (_end == _RecurrenceEnd.until) + DesktopDateValueRow( + label: l10n.repeatUntil, + date: + _value.untilDateFor(timeZone: widget.timeZone) ?? + _dateString(_oneMonthAfter(widget.baseDate)), + useNativePicker: widget.useNativeDatePicker, + onChanged: _setUntilDate, + ), + if (_end == _RecurrenceEnd.count) + YaruListTile.square( + title: TextField( + controller: _countController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: l10n.repeatCount, + ), + onChanged: (value) { + final parsed = int.tryParse(value); + setState(() { + if (parsed != null && parsed >= 1 && parsed <= 3500) { + _value = _value.copyWith(count: parsed); + } + }); + }, + ), + ), + ], + ), + ], + ); + } + + bool get _isValid { + if (!_value.repeats) return true; + final interval = int.tryParse(_intervalController.text); + if (interval == null || interval < 1 || interval > 366) return false; + if (_value.frequency == RecurrenceFrequency.weekly && + _value.byDay.isEmpty) { + return false; + } + if ((_value.frequency == RecurrenceFrequency.monthly || + _value.frequency == RecurrenceFrequency.yearly) && + _value.byMonthDay.isEmpty && + (_value.bySetPosition == null || _value.byDay.isEmpty)) { + return false; + } + if (_value.frequency == RecurrenceFrequency.yearly && + _value.byMonth.isEmpty) { + return false; + } + if (_end == _RecurrenceEnd.until && _value.untilRaw == null) return false; + if (_end == _RecurrenceEnd.count) { + final count = int.tryParse(_countController.text); + if (count == null || count < 1 || count > 3500) return false; + } + return widget.limits.supports(_value); + } + + void _setFrequency(RecurrenceFrequency frequency) { + final base = widget.baseDate; + setState(() { + _value = switch (frequency) { + RecurrenceFrequency.none => _value.copyWith( + frequency: frequency, + byDay: const [], + byMonth: const [], + byMonthDay: const [], + bySetPosition: null, + count: null, + untilRaw: null, + ), + RecurrenceFrequency.weekly => _value.copyWith( + frequency: frequency, + byDay: [_weekdayCode(base.weekday)], + byMonth: const [], + byMonthDay: const [], + bySetPosition: null, + ), + RecurrenceFrequency.monthly => _value.copyWith( + frequency: frequency, + byDay: const [], + byMonth: const [], + byMonthDay: [base.day], + bySetPosition: null, + ), + RecurrenceFrequency.yearly => _value.copyWith( + frequency: frequency, + byDay: const [], + byMonth: [base.month], + byMonthDay: [base.day], + bySetPosition: null, + ), + RecurrenceFrequency.daily => _value.copyWith( + frequency: frequency, + byDay: const [], + byMonth: const [], + byMonthDay: const [], + bySetPosition: null, + ), + }; + }); + } + + void _toggleWeekday(String day, bool selected) { + final values = [..._value.byDay]; + if (selected) { + if (!values.contains(day)) values.add(day); + } else if (values.length > 1) { + values.remove(day); + } + values.sort( + (left, right) => + rfcWeekdays.indexOf(left).compareTo(rfcWeekdays.indexOf(right)), + ); + setState(() => _value = _value.copyWith(byDay: values)); + } + + void _toggleMonthDay(int day, bool selected) { + var values = [..._value.byMonthDay]; + if (!widget.limits.allowMultipleMonthDays) { + values = [day]; + } else if (selected) { + if (!values.contains(day)) values.add(day); + } else if (values.length > 1) { + values.remove(day); + } + values.sort(); + setState(() { + _value = _value.copyWith( + byMonthDay: values, + byDay: const [], + bySetPosition: null, + ); + }); + } + + void _toggleMonth(int month, bool selected) { + var values = [..._value.byMonth]; + if (!widget.limits.allowMultipleMonths || _value.bySetPosition != null) { + values = [month]; + } else if (selected) { + if (!values.contains(month)) values.add(month); + } else if (values.length > 1) { + values.remove(month); + } + values.sort(); + setState(() => _value = _value.copyWith(byMonth: values)); + } + + void _setOrdinal(int? value) { + setState(() { + if (value == null || value == 0) { + _value = _value.copyWith( + bySetPosition: null, + byDay: const [], + byMonthDay: _value.byMonthDay.isEmpty + ? [widget.baseDate.day] + : [_value.byMonthDay.first], + ); + } else { + _value = _value.copyWith( + bySetPosition: value, + byDay: _value.byDay.isEmpty + ? [_weekdayCode(widget.baseDate.weekday)] + : _value.byDay, + byMonthDay: const [], + byMonth: + _value.frequency == RecurrenceFrequency.yearly && + _value.byMonth.length > 1 + ? [_value.byMonth.first] + : _value.byMonth, + ); + } + }); + } + + void _setEnd(_RecurrenceEnd value) { + setState(() { + _end = value; + _value = switch (value) { + _RecurrenceEnd.never => _value.copyWith(count: null, untilRaw: null), + _RecurrenceEnd.count => _value.copyWith( + count: int.tryParse(_countController.text) ?? 10, + untilRaw: null, + ), + _RecurrenceEnd.until => + _value + .copyWith(count: null) + .withUntilDate( + _value.untilDateFor(timeZone: widget.timeZone) ?? + _dateString(_oneMonthAfter(widget.baseDate)), + allDay: widget.allDay, + floating: widget.floating, + baseDate: widget.baseDate, + timeZone: widget.timeZone, + ), + }; + }); + } + + void _setUntilDate(String value) { + final date = DateTime.tryParse(value); + if (date == null) return; + final firstDate = DateTime( + widget.minimumDate.year, + widget.minimumDate.month, + widget.minimumDate.day, + ); + if (date.isBefore(firstDate)) return; + setState(() { + _value = _value.withUntilDate( + _dateString(date), + allDay: widget.allDay, + floating: widget.floating, + baseDate: widget.baseDate, + timeZone: widget.timeZone, + ); + }); + } + + String _frequencyLabel(RecurrenceFrequency frequency) => switch (frequency) { + RecurrenceFrequency.none => context.l10n.repeatNone, + RecurrenceFrequency.daily => context.l10n.repeatDaily, + RecurrenceFrequency.weekly => context.l10n.repeatWeekly, + RecurrenceFrequency.monthly => context.l10n.repeatMonthly, + RecurrenceFrequency.yearly => context.l10n.repeatYearly, + }; +} + +const _ordinalPositions = [1, 2, 3, 4, 5, -2, -1]; + +typedef _OrdinalDayChoice = ({String key, List days}); + +const _ordinalDayChoices = <_OrdinalDayChoice>[ + (key: 'MO', days: ['MO']), + (key: 'TU', days: ['TU']), + (key: 'WE', days: ['WE']), + (key: 'TH', days: ['TH']), + (key: 'FR', days: ['FR']), + (key: 'SA', days: ['SA']), + (key: 'SU', days: ['SU']), + (key: 'day', days: ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']), + (key: 'weekday', days: ['MO', 'TU', 'WE', 'TH', 'FR']), + (key: 'weekend', days: ['SA', 'SU']), +]; + +String _weekdayCode(int weekday) => rfcWeekdays[(weekday - 1).clamp(0, 6)]; + +String _ordinalDayChoiceKey(List days) { + for (final choice in _ordinalDayChoices) { + if (_sameDaySet(choice.days, days)) return choice.key; + } + return days.firstOrNull ?? 'MO'; +} + +DateTime? _weekdayDate(String day) { + final index = rfcWeekdays.indexOf(day); + if (index < 0) return null; + return DateTime(2024, 1, 1 + index); +} + +String _localizedStandaloneWeekday( + BuildContext context, + String day, { + required bool abbreviated, +}) { + final date = _weekdayDate(day); + if (date == null) return day; + final locale = Localizations.localeOf(context).toLanguageTag(); + return localizedWeekdayLabel(locale, date, abbreviated: abbreviated); +} + +String _localizedInlineWeekday(BuildContext context, String day) { + final date = _weekdayDate(day); + if (date == null) return day; + final locale = Localizations.localeOf(context).toLanguageTag(); + return localizedInlineWeekday(locale, date); +} + +String _localizedStandaloneMonth(BuildContext context, int month) { + if (month < 1 || month > 12) return '$month'; + final locale = Localizations.localeOf(context).toLanguageTag(); + return localizedMonthLabel(locale, DateTime(2024, month), abbreviated: true); +} + +String _localizedDayNumber(BuildContext context, int day) { + final locale = Localizations.localeOf(context).toLanguageTag(); + return localizedNumber(locale, day); +} + +String _localizedRecurrenceList( + List values, { + required String Function(String first, String second) pair, + required String Function(String first, String rest) start, +}) { + assert(values.isNotEmpty); + if (values.length == 1) return values.single; + + var result = pair(values[values.length - 2], values.last); + for (var index = values.length - 3; index >= 0; index -= 1) { + result = start(values[index], result); + } + return result; +} + +String _localizedInlineMonth(BuildContext context, int month) { + if (month < 1 || month > 12) return '$month'; + final locale = Localizations.localeOf(context).toLanguageTag(); + return localizedInlineMonth(locale, DateTime(2024, month), abbreviated: true); +} + +String _localizedYearlyMonth(BuildContext context, int month) { + final localizedMonth = _localizedInlineMonth(context, month); + return context.l10n.repeatYearlyMonthValue( + localizedMonth, + _recurrenceMonthKey(month), + ); +} + +String _recurrenceMonthKey(int month) => switch (month) { + 1 => 'jan', + 2 => 'feb', + 3 => 'mar', + 4 => 'apr', + 5 => 'may', + 6 => 'jun', + 7 => 'jul', + 8 => 'aug', + 9 => 'sep', + 10 => 'oct', + 11 => 'nov', + 12 => 'dec', + _ => 'other', +}; + +String _localizedOrdinal(BuildContext context, int value) => switch (value) { + 1 => context.l10n.repeatFirst, + 2 => context.l10n.repeatSecond, + 3 => context.l10n.repeatThird, + 4 => context.l10n.repeatFourth, + 5 => context.l10n.repeatFifth, + -2 => context.l10n.repeatSecondToLast, + -1 => context.l10n.repeatLast, + _ => '$value', +}; + +String _ordinalPositionKey(int value) => switch (value) { + 1 => 'first', + 2 => 'second', + 3 => 'third', + 4 => 'fourth', + 5 => 'fifth', + -2 => 'secondToLast', + -1 => 'last', + _ => '$value', +}; + +String _localizedOrdinalDayChoice(BuildContext context, List days) { + if (days.length == 1) { + return _localizedStandaloneWeekday( + context, + days.single, + abbreviated: false, + ); + } + if (_sameDaySet(days, _ordinalDayChoices[7].days)) { + return context.l10n.repeatAnyDay; + } + if (_sameDaySet(days, _ordinalDayChoices[8].days)) { + return context.l10n.repeatWeekday; + } + if (_sameDaySet(days, _ordinalDayChoices[9].days)) { + return context.l10n.repeatWeekendDay; + } + final dayValues = days + .map( + (day) => _localizedStandaloneWeekday(context, day, abbreviated: false), + ) + .toList(growable: false); + return _localizedRecurrenceList( + dayValues, + pair: context.l10n.repeatWeekdayListPair, + start: context.l10n.repeatWeekdayListStart, + ); +} + +String _localizedOrdinalDaySummary(BuildContext context, List days) { + final dayKey = _ordinalDayChoiceKey(days); + if (days.length == 1) { + return context.l10n.repeatOrdinalDaySummary( + dayKey, + _localizedInlineWeekday(context, days.single), + ); + } + if (dayKey == 'day' || dayKey == 'weekday' || dayKey == 'weekend') { + return context.l10n.repeatOrdinalDaySummary(dayKey, ''); + } + final dayValues = days + .map((day) => _localizedInlineWeekday(context, day)) + .toList(growable: false); + final localizedDays = _localizedRecurrenceList( + dayValues, + pair: context.l10n.repeatWeekdayListPair, + start: context.l10n.repeatWeekdayListStart, + ); + return context.l10n.repeatOrdinalDaySummary(dayKey, localizedDays); +} + +String _localizedWeeklyDaySummary(BuildContext context, String day) { + return context.l10n.repeatWeeklyDaySummary( + day, + _localizedInlineWeekday(context, day), + ); +} + +bool _sameDaySet(List left, List right) { + if (left.length != right.length) return false; + final leftValues = {...left}; + return leftValues.length == left.length && leftValues.containsAll(right); +} + +DateTime _oneMonthAfter(DateTime value) { + final firstOfTarget = DateTime(value.year, value.month + 1); + final lastDay = DateTime(firstOfTarget.year, firstOfTarget.month + 1, 0).day; + final day = value.day > lastDay ? lastDay : value.day; + return DateTime(firstOfTarget.year, firstOfTarget.month, day); +} + +String _dateString(DateTime value) => + '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; diff --git a/lib/src/features/schedule/presentation/calendar_day_semantics.dart b/lib/src/features/schedule/presentation/calendar_day_semantics.dart index 13f71c59..445cfc74 100644 --- a/lib/src/features/schedule/presentation/calendar_day_semantics.dart +++ b/lib/src/features/schedule/presentation/calendar_day_semantics.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; + +import '../../../l10n/localized_formatters.dart'; /// Gives every interactive calendar day one consistent desktop accessibility /// contract, regardless of the visual calendar that renders it. @@ -20,7 +21,7 @@ class BusyMaxCalendarDaySemantics extends StatelessWidget { @override Widget build(BuildContext context) { final locale = Localizations.localeOf(context).toLanguageTag(); - final label = DateFormat.yMMMMEEEEd(locale).format(day); + final label = localizedDayHeading(locale, day); return Semantics( container: true, diff --git a/lib/src/features/schedule/presentation/mini_calendar.dart b/lib/src/features/schedule/presentation/mini_calendar.dart index 69a417d3..f9d6a489 100644 --- a/lib/src/features/schedule/presentation/mini_calendar.dart +++ b/lib/src/features/schedule/presentation/mini_calendar.dart @@ -1,7 +1,6 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_glyphs.dart'; @@ -170,7 +169,7 @@ class YearMonthMiniCalendar extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _MiniCalendarHeaderLabel( - label: DateFormat.yMMMM(locale).format(month), + label: localizedMonthYearHeading(locale, month), tooltip: context.l10n.openMonthView, onPressed: () => onMonthSelected(month), ), @@ -254,7 +253,11 @@ class MiniCalendarGrid extends StatelessWidget { Expanded( child: Center( child: Text( - DateFormat.E(locale).format(_weekdayDate(weekday)), + localizedWeekdayLabel( + locale, + _weekdayDate(weekday), + abbreviated: true, + ), maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index 76eedaca..b06f6983 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -6,6 +6,7 @@ import '../../../app/busymax_design.dart'; import '../../../app/busymax_glyphs.dart'; import '../../../app/busymax_surface_colors.dart'; import '../../../l10n/l10n.dart'; +import '../../../l10n/localized_formatters.dart'; import '../../tasks/domain/task_checklist_item.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; @@ -621,7 +622,7 @@ String _dayLabel(BuildContext context, DateTime day) { return context.l10n.tomorrow; } final locale = Localizations.localeOf(context).toLanguageTag(); - return DateFormat.yMMMMEEEEd(locale).format(day); + return localizedDayHeading(locale, day); } String _nestedTaskScheduleLabel( diff --git a/lib/src/features/schedule/presentation/schedule_day_week_view.dart b/lib/src/features/schedule/presentation/schedule_day_week_view.dart index dce5e8f0..97f02ed9 100644 --- a/lib/src/features/schedule/presentation/schedule_day_week_view.dart +++ b/lib/src/features/schedule/presentation/schedule_day_week_view.dart @@ -2,12 +2,12 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:infinite_calendar_view/infinite_calendar_view.dart' as icv; -import 'package:intl/intl.dart'; import '../../../app/app_settings.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_surface_colors.dart'; import '../../../l10n/l10n.dart'; +import '../../../l10n/localized_formatters.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import '../../../schedule/schedule_range.dart'; @@ -484,7 +484,6 @@ class _PlannerDayHeader extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final surfaceColors = BusyMaxSurfaceColors.of(context); - final locale = Localizations.localeOf(context).toLanguageTag(); return Container( alignment: Alignment.center, decoration: BoxDecoration( @@ -497,7 +496,11 @@ class _PlannerDayHeader extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - DateFormat.E(locale).format(day), + localizedWeekdayLabel( + Localizations.localeOf(context).toLanguageTag(), + day, + abbreviated: true, + ), maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall?.copyWith( diff --git a/lib/src/features/schedule/presentation/schedule_month_view.dart b/lib/src/features/schedule/presentation/schedule_month_view.dart index 4e0e8c19..9ec17c5c 100644 --- a/lib/src/features/schedule/presentation/schedule_month_view.dart +++ b/lib/src/features/schedule/presentation/schedule_month_view.dart @@ -1,12 +1,12 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_surface_colors.dart'; import '../../../l10n/l10n.dart'; +import '../../../l10n/localized_formatters.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import '../../../schedule/schedule_range.dart'; @@ -45,7 +45,6 @@ class ScheduleMonthView extends StatelessWidget { Widget build(BuildContext context) { final days = _daysInRange(range); final rows = (days.length / DateTime.daysPerWeek).ceil(); - final locale = Localizations.localeOf(context).toLanguageTag(); final month = DateTime(selectedDate.year, selectedDate.month); final grouped = ScheduleProjection.groupByDay(items); final theme = Theme.of(context); @@ -64,7 +63,11 @@ class ScheduleMonthView extends StatelessWidget { Expanded( child: Center( child: Text( - DateFormat.E(locale).format(_weekdayDate(weekday)), + localizedWeekdayLabel( + Localizations.localeOf(context).toLanguageTag(), + _weekdayDate(weekday), + abbreviated: true, + ), maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.labelMedium?.copyWith( diff --git a/lib/src/features/schedule/presentation/schedule_more_popover.dart b/lib/src/features/schedule/presentation/schedule_more_popover.dart index 812668b7..7dd45bad 100644 --- a/lib/src/features/schedule/presentation/schedule_more_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_more_popover.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_yaru_theme.dart'; +import '../../../l10n/localized_formatters.dart'; import '../../../schedule/schedule_item.dart'; import 'schedule_anchored_popover.dart'; import 'schedule_item_chip.dart'; @@ -26,7 +26,7 @@ Future showScheduleMorePopover({ onTaskCompletionChanged, }) async { final locale = Localizations.localeOf(context).toLanguageTag(); - final dayLabel = DateFormat.yMMMMEEEEd(locale).format(day); + final dayLabel = localizedDayHeading(locale, day); final fallbackAnchorPoint = scheduleGlobalRectFor(anchorContext)?.center; return showScheduleAnchoredPopover( context: context, diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index c2960df5..67bc52a4 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -11,11 +11,17 @@ import '../../../app/busymax_dialogs.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_glyphs.dart'; import '../../../calendar_providers/calendar_colors.dart'; +import '../../../calendar_providers/calendar_provider_capabilities.dart'; import '../../../l10n/l10n.dart'; +import '../../../platform/linux_header_bar_provider.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; +import '../../../webcal/webcal_subscription_service.dart'; import '../../accounts/data/accounts_repository.dart'; +import '../../accounts/domain/account_collection_creation_capabilities.dart'; +import '../../calendar/data/calendar_collection_creation_service.dart'; import '../../calendar/data/calendar_repository.dart'; +import '../../calendar/presentation/calendar_color_dialog.dart'; import '../../sync/sync_auth_error.dart'; import '../../task_lists/data/task_lists_repository.dart'; import '../../tasks/domain/task_capabilities.dart'; @@ -62,11 +68,20 @@ class ScheduleSidebar extends ConsumerWidget { child: ListView( padding: const EdgeInsets.symmetric(vertical: BusyMaxSpacing.sm), children: [ - for (final account in accounts) + for (final account in accounts.where( + (account) => !account.isSubscription, + )) _AccountSourcesGroup( key: ValueKey(('schedule-account', account.id)), account: account, ), + if (accounts.any((account) => account.isSubscription)) + _SubscriptionSourcesGroup( + accounts: [ + for (final account in accounts) + if (account.isSubscription) account, + ], + ), ], ), ), @@ -85,6 +100,7 @@ class _SourceRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final providerWebUri = scheduleCalendarProviderWebUri(account, source); + final capabilities = source.capabilities; return _CompactSourceRow( title: source.summary, leading: _SourceDot( @@ -106,6 +122,7 @@ class _SourceRow extends ConsumerWidget { }, ), menuButton: BusyMaxMenuButton( + key: ValueKey(('calendar-options', source.id)), tooltip: context.l10n.options, highlightWhenOpen: false, onSelected: (value) { @@ -117,14 +134,9 @@ class _SourceRow extends ConsumerWidget { unawaited(_openProviderWeb(providerWebUri)); } case 'toggle-reminders': - unawaited( - ref - .read(calendarRepositoryProvider) - .setSourceRemindersEnabled( - source.id, - !source.remindersEnabled, - ), - ); + unawaited(_setCalendarRemindersEnabled(context, ref, source)); + case 'color': + unawaited(_changeCalendarColor(context, ref, source)); case 'rename': unawaited(_renameCalendar(context, ref, source)); case 'delete': @@ -145,23 +157,38 @@ class _SourceRow extends ConsumerWidget { ), BusyMaxMenuEntry( value: 'toggle-reminders', - label: context.l10n.eventReminders, - icon: Icons.notifications_outlined, - selected: source.remindersEnabled, + label: + '${context.l10n.eventReminders} — ' + '${source.remindersEnabled ? context.l10n.onState : context.l10n.off}', + icon: source.remindersEnabled + ? Icons.notifications_outlined + : Icons.notifications_off_outlined, + ), + BusyMaxMenuEntry( + value: 'color', + label: context.l10n.calendarColor, + icon: Icons.palette_outlined, + enabled: capabilities.canChangeCalendarColor, + tooltip: _calendarColorRestriction(context, source), ), BusyMaxMenuEntry( value: 'rename', - label: context.l10n.rename, + label: capabilities.renameMode == CalendarRenameMode.personal + ? context.l10n.setCustomCalendarName + : context.l10n.rename, icon: Icons.edit_outlined, - enabled: !source.readOnly, - tooltip: source.readOnly ? context.l10n.readOnlyCalendar : null, + enabled: capabilities.canRenameCalendar, + tooltip: _calendarRenameRestriction(context, source), ), BusyMaxMenuEntry( value: 'delete', - label: context.l10n.delete, + label: + capabilities.removalMode == CalendarRemovalMode.removeFromList + ? context.l10n.removeFromMyCalendars + : context.l10n.delete, icon: YaruIcons.trash, - enabled: !source.readOnly, - tooltip: source.readOnly ? context.l10n.readOnlyCalendar : null, + enabled: capabilities.canRemoveCalendar, + tooltip: _calendarDeleteRestriction(context, source), destructive: true, ), ], @@ -288,6 +315,8 @@ class _AccountSourcesGroup extends ConsumerStatefulWidget { class _AccountSourcesGroupState extends ConsumerState<_AccountSourcesGroup> { var _expanded = true; + var _creatingCalendar = false; + var _creatingTaskList = false; @override void didUpdateWidget(covariant _AccountSourcesGroup oldWidget) { @@ -300,6 +329,27 @@ class _AccountSourcesGroupState extends ConsumerState<_AccountSourcesGroup> { @override Widget build(BuildContext context) { final account = widget.account; + final networkAvailability = + ref.watch(networkAvailabilityProvider).valueOrNull ?? + ref.read(networkConnectivityMonitorProvider).availability; + final capabilities = AccountCollectionCreationCapabilities.resolve( + account: account, + networkAvailability: networkAvailability, + calendarCreationRunning: _creatingCalendar, + taskListCreationRunning: _creatingTaskList, + ); + final headerActions = <_AccountHeaderCollectionAction>[ + if (capabilities.supportsCalendarCreation) + _AccountHeaderCollectionAction( + action: AccountHeaderCollectionAction.newCalendar, + enabled: capabilities.calendarActionEnabled, + ), + if (capabilities.supportsTaskListCreation) + _AccountHeaderCollectionAction( + action: AccountHeaderCollectionAction.newTaskList, + enabled: capabilities.taskListActionEnabled, + ), + ]; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -307,6 +357,8 @@ class _AccountSourcesGroupState extends ConsumerState<_AccountSourcesGroup> { account: account, expanded: _expanded, onToggleExpanded: _toggleExpanded, + actions: headerActions, + onActionSelected: _handleCollectionAction, ), if (_expanded) ...[ _AccountCalendarSources(account: account), @@ -343,6 +395,137 @@ class _AccountSourcesGroupState extends ConsumerState<_AccountSourcesGroup> { void _toggleExpanded() { setState(() => _expanded = !_expanded); } + + Future _handleCollectionAction( + AccountHeaderCollectionAction action, + ) async { + switch (action) { + case AccountHeaderCollectionAction.newCalendar: + if (_creatingCalendar) return; + setState(() => _creatingCalendar = true); + try { + await _createAccountCalendar(context, ref, widget.account); + } finally { + if (mounted) setState(() => _creatingCalendar = false); + } + case AccountHeaderCollectionAction.newTaskList: + if (_creatingTaskList) return; + setState(() => _creatingTaskList = true); + try { + await _createAccountTaskList(context, ref, widget.account); + } finally { + if (mounted) setState(() => _creatingTaskList = false); + } + } + } +} + +class _SubscriptionSourcesGroup extends ConsumerWidget { + const _SubscriptionSourcesGroup({required this.accounts}); + + final List accounts; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final accountById = {for (final account in accounts) account.id: account}; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsetsDirectional.only( + start: BusyMaxSpacing.md, + top: BusyMaxSpacing.md, + end: BusyMaxSpacing.md, + bottom: BusyMaxSpacing.xs, + ), + child: Text( + context.l10n.subscriptions, + style: Theme.of(context).textTheme.labelLarge, + ), + ), + StreamBuilder>( + stream: ref + .watch(calendarRepositoryProvider) + .watchSourcesForAccounts(accountById.keys.toList()), + builder: (context, snapshot) { + final sources = (snapshot.data ?? const []) + .where((source) => source.provider == BusyProvider.webCal); + return Column( + children: [ + for (final source in sources) + _SubscriptionSourceRow( + key: ValueKey(('subscription-source', source.id)), + source: source, + ), + ], + ); + }, + ), + ], + ); + } +} + +class _SubscriptionSourceRow extends ConsumerWidget { + const _SubscriptionSourceRow({super.key, required this.source}); + + final CalendarSourceEntity source; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return _CompactSourceRow( + title: '${source.summary} · ${context.l10n.readOnlySharedCollection}', + leading: _SourceDot(seed: source.id, colorHex: source.backgroundColor), + trailing: _SourceRowActions( + visibilityButton: _SourceVisibilityButton( + value: source.selected && !source.hidden, + semanticLabel: source.summary, + onChanged: (value) => ref + .read(calendarRepositoryProvider) + .setSourceSelected(source.id, value), + ), + menuButton: BusyMaxMenuButton( + tooltip: context.l10n.options, + highlightWhenOpen: false, + onSelected: (value) { + switch (value) { + case 'refresh': + unawaited(_refreshSubscriptionSource(context, ref, source)); + case 'rename': + unawaited(_renameSubscriptionSource(context, ref, source)); + case 'color': + unawaited(_colorSubscriptionSource(context, ref, source)); + case 'unsubscribe': + unawaited(_unsubscribeSource(context, ref, source)); + } + }, + entries: [ + BusyMaxMenuEntry( + value: 'refresh', + label: context.l10n.refreshNow, + icon: YaruIcons.refresh, + ), + BusyMaxMenuEntry( + value: 'rename', + label: context.l10n.rename, + icon: Icons.edit_outlined, + ), + BusyMaxMenuEntry( + value: 'color', + label: context.l10n.calendarColor, + icon: Icons.palette_outlined, + ), + BusyMaxMenuEntry( + value: 'unsubscribe', + label: context.l10n.unsubscribe, + icon: YaruIcons.trash, + destructive: true, + ), + ], + ), + ), + ); + } } class _AccountHeaderRow extends StatelessWidget { @@ -350,16 +533,28 @@ class _AccountHeaderRow extends StatelessWidget { required this.account, required this.expanded, required this.onToggleExpanded, + required this.actions, + required this.onActionSelected, }); final AccountEntity account; final bool expanded; final VoidCallback onToggleExpanded; + final List<_AccountHeaderCollectionAction> actions; + final ValueChanged onActionSelected; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final secondaryLabel = account.secondaryLabel; + final secondaryLabel = _accountHeaderSecondaryLabel(account); + final hasSecondaryLabel = + secondaryLabel != null && secondaryLabel.isNotEmpty; + final providerName = _accountProviderName(context, account.provider); + final accountSemanticsLabel = [ + providerName, + account.displayLabel, + if (hasSecondaryLabel) secondaryLabel, + ].join(', '); return Padding( padding: const EdgeInsetsDirectional.only( start: BusyMaxSpacing.md, @@ -370,29 +565,100 @@ class _AccountHeaderRow extends StatelessWidget { child: Row( children: [ Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - account.displayLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelLarge, + child: Semantics( + key: ValueKey(('account-identity', account.id)), + container: true, + label: accountSemanticsLabel, + child: ExcludeSemantics( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasSecondaryLabel) + Text( + account.displayLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelLarge, + ), + Row( + children: [ + Expanded( + child: Text( + hasSecondaryLabel + ? secondaryLabel + : account.displayLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: hasSecondaryLabel + ? Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ) + : Theme.of(context).textTheme.labelLarge, + ), + ), + const SizedBox(width: BusyMaxSpacing.xs), + _AccountProviderIndicator( + key: ValueKey(( + 'account-provider-indicator', + account.id, + )), + provider: account.provider, + providerName: providerName, + ), + ], + ), + ], ), - if (secondaryLabel != null && secondaryLabel.isNotEmpty) - Text( - secondaryLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: BusyMaxSpacing.xs), + if (actions.isNotEmpty) ...[ + BusyMaxMenuButton( + key: ValueKey(('account-collection-options', account.id)), + tooltip: context.l10n.options, + highlightWhenOpen: false, + triggerBuilder: (context, trigger) => trigger.anchor( + child: Semantics( + expanded: trigger.isOpen, + button: true, + child: BusyMaxHeaderIconButton( + tooltip: context.l10n.options, + iconSize: BusyMaxSizes.sidebarActionIcon, + icon: const Icon(YaruIcons.view_more), + focusNode: trigger.focusNode, + onPressed: trigger.onPressed, + foregroundColor: colorScheme.onSurfaceVariant, + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll( + Colors.transparent, ), ), + ), + ), + onSelected: onActionSelected, + entries: [ + for (final item in actions) + BusyMaxMenuEntry( + value: item.action, + label: switch (item.action) { + AccountHeaderCollectionAction.newCalendar => + '${context.l10n.newCalendar}…', + AccountHeaderCollectionAction.newTaskList => + '${context.l10n.newTaskList}…', + }, + icon: YaruIcons.plus, + enabled: item.enabled, + ), ], ), - ), - const SizedBox(width: BusyMaxSpacing.xs), + const SizedBox(width: BusyMaxSpacing.xs), + ], BusyMaxHeaderIconButton( + key: ValueKey(('account-collapse', account.id)), tooltip: expanded ? MaterialLocalizations.of(context).expandedIconTapHint : MaterialLocalizations.of(context).collapsedIconTapHint, @@ -416,6 +682,86 @@ class _AccountHeaderRow extends StatelessWidget { } } +String? _accountHeaderSecondaryLabel(AccountEntity account) { + final secondaryLabel = account.secondaryLabel; + if (secondaryLabel != null && secondaryLabel.isNotEmpty) { + return secondaryLabel; + } + if (account.provider != BusyProvider.nextcloud) return null; + + final authority = Uri.tryParse(account.authority); + if (authority == null || authority.host.isEmpty) return null; + + final scheme = authority.scheme.toLowerCase(); + final hasDefaultPort = + (scheme == 'https' && authority.port == 443) || + (scheme == 'http' && authority.port == 80); + if (!authority.hasPort || hasDefaultPort) return authority.host; + + final host = authority.host.contains(':') + ? '[${authority.host}]' + : authority.host; + return '$host:${authority.port}'; +} + +class _AccountProviderIndicator extends StatelessWidget { + const _AccountProviderIndicator({ + super.key, + required this.provider, + required this.providerName, + }); + + static const double _diameter = 16; + + final BusyProvider provider; + final String providerName; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Tooltip( + message: providerName, + excludeFromSemantics: true, + child: SizedBox.square( + dimension: _diameter, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorScheme.surfaceContainerHighest, + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Center( + child: Text( + _accountProviderLetter(provider), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ); + } +} + +String _accountProviderLetter(BusyProvider provider) => switch (provider) { + BusyProvider.google => 'G', + BusyProvider.microsoft => 'M', + BusyProvider.nextcloud => 'N', + BusyProvider.appleICloud => 'A', + BusyProvider.webCal => 'W', +}; + +String _accountProviderName(BuildContext context, BusyProvider provider) => + switch (provider) { + BusyProvider.google => context.l10n.googleProvider, + BusyProvider.microsoft => context.l10n.microsoftProvider, + BusyProvider.nextcloud => context.l10n.nextcloudProvider, + BusyProvider.appleICloud => context.l10n.appleICloudProvider, + BusyProvider.webCal => 'WebCal', + }; + class _AccountCalendarSources extends ConsumerWidget { const _AccountCalendarSources({required this.account}); @@ -437,14 +783,13 @@ class _AccountCalendarSources extends ConsumerWidget { ]), builder: (context, snapshot) { final sources = snapshot.data ?? const []; - if (sources.isEmpty) { - return BusyMaxActionRow( - title: context.l10n.noCalendarsSynced, - leading: const Icon(YaruIcons.calendar), - ); - } return Column( children: [ + if (sources.isEmpty) + BusyMaxActionRow( + title: context.l10n.noCalendarsSynced, + leading: const Icon(YaruIcons.calendar), + ), for (final source in sources) _SourceRow( key: ValueKey(( @@ -549,6 +894,7 @@ class _TaskListScheduleRow extends ConsumerWidget { }, ), menuButton: BusyMaxMenuButton( + key: ValueKey(('task-list-options', list.accountId, list.id)), tooltip: context.l10n.options, highlightWhenOpen: false, onSelected: (value) { @@ -559,6 +905,8 @@ class _TaskListScheduleRow extends ConsumerWidget { if (providerWebUri != null) { unawaited(_openProviderWeb(providerWebUri)); } + case 'toggle-reminders': + unawaited(_setTaskListRemindersEnabled(context, ref, list)); case 'rename': unawaited(_renameTaskList(context, ref, list)); case 'delete': @@ -577,6 +925,15 @@ class _TaskListScheduleRow extends ConsumerWidget { label: context.l10n.openInProvider, icon: Icons.open_in_browser_outlined, ), + BusyMaxMenuEntry( + value: 'toggle-reminders', + label: + '${context.l10n.taskReminders} — ' + '${list.remindersEnabled ? context.l10n.onState : context.l10n.off}', + icon: list.remindersEnabled + ? Icons.notifications_outlined + : Icons.notifications_off_outlined, + ), BusyMaxMenuEntry( value: 'rename', label: context.l10n.rename, @@ -614,6 +971,7 @@ String scheduleTaskListLabel( BusyProvider.microsoft => context.l10n.microsoftTodoProvider, BusyProvider.appleICloud => context.l10n.appleICloudTasksProvider, BusyProvider.nextcloud => context.l10n.nextcloudTasksProvider, + BusyProvider.webCal => 'WebCal', }; final title = list.title.trim(); if (title.isEmpty || @@ -651,6 +1009,7 @@ Uri? scheduleCalendarProviderWebUri( ), BusyProvider.appleICloud => null, BusyProvider.nextcloud => _safeAccountWebUri(account.authority), + BusyProvider.webCal => null, }; } @@ -661,6 +1020,7 @@ Uri? scheduleTaskProviderWebUri(AccountEntity account) { BusyProvider.microsoft => Uri.https('to-do.office.com', '/tasks/'), BusyProvider.appleICloud => null, BusyProvider.nextcloud => _safeAccountWebUri(account.authority), + BusyProvider.webCal => null, }; } @@ -801,6 +1161,193 @@ String? _taskListDeleteRestriction( return context.l10n.taskListCannotDelete; } +String? _calendarRenameRestriction( + BuildContext context, + CalendarSourceEntity source, +) { + if (source.capabilities.canRenameCalendar) return null; + if (!calendarManagementCapabilities(source.provider).supportsRename) { + return context.l10n.calendarManagementUnsupported; + } + return context.l10n.readOnlyCalendar; +} + +String? _calendarColorRestriction( + BuildContext context, + CalendarSourceEntity source, +) { + if (source.capabilities.canChangeCalendarColor) return null; + if (!calendarManagementCapabilities(source.provider).supportsColor) { + return context.l10n.calendarManagementUnsupported; + } + return context.l10n.readOnlyCalendar; +} + +String? _calendarDeleteRestriction( + BuildContext context, + CalendarSourceEntity source, +) { + if (source.capabilities.canRemoveCalendar) return null; + if (!calendarManagementCapabilities(source.provider).supportsDelete) { + return context.l10n.calendarManagementUnsupported; + } + if (source.primaryCalendar) { + return context.l10n.primaryCalendarCannotDelete; + } + return context.l10n.calendarCannotRemove; +} + +enum AccountHeaderCollectionAction { newCalendar, newTaskList } + +final class _AccountHeaderCollectionAction { + const _AccountHeaderCollectionAction({ + required this.action, + required this.enabled, + }); + + final AccountHeaderCollectionAction action; + final bool enabled; +} + +Future _createAccountCalendar( + BuildContext context, + WidgetRef ref, + AccountEntity account, +) async { + final title = await showBusyMaxTextPrompt( + context, + title: context.l10n.newCalendar, + label: context.l10n.title, + actionLabel: context.l10n.create, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (!context.mounted || title == null || title.trim().isEmpty) return; + try { + final result = await ref + .read(calendarCollectionCreationServiceProvider) + .createCalendar(accountId: account.id, title: title); + if (context.mounted && + result.outcome == + CalendarCollectionCreationOutcome.createdRefreshPending) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.calendarCreatedRefreshPending)), + ); + } + } on Object catch (error) { + if (context.mounted) { + _showCalendarMutationFailure( + context, + context.l10n.calendarCreateFailed( + syncFailureMessage( + error, + networkUnavailableMessage: context.l10n.networkOfflineTryAgain, + ), + ), + ); + } + } +} + +Future _createAccountTaskList( + BuildContext context, + WidgetRef ref, + AccountEntity account, +) async { + final title = await showBusyMaxTextPrompt( + context, + title: context.l10n.newTaskList, + label: context.l10n.title, + actionLabel: context.l10n.create, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (!context.mounted || title == null || title.trim().isEmpty) return; + try { + await ref + .read(taskListsRepositoryForAccountProvider(account.id)) + .createTaskList(title); + } on Object catch (error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.taskListCreateFailed( + syncFailureMessage( + error, + networkUnavailableMessage: context.l10n.networkOfflineTryAgain, + ), + ), + ), + ), + ); + } + } +} + +Future _setCalendarRemindersEnabled( + BuildContext context, + WidgetRef ref, + CalendarSourceEntity source, +) async { + try { + await ref + .read(calendarRepositoryProvider) + .setSourceRemindersEnabled(source.id, !source.remindersEnabled); + } on Object { + if (context.mounted) { + _showCalendarMutationFailure(context, context.l10n.operationFailed); + } + } +} + +Future _setTaskListRemindersEnabled( + BuildContext context, + WidgetRef ref, + TaskListEntity list, +) async { + try { + await ref + .read(taskListsRepositoryForAccountProvider(list.accountId)) + .setRemindersEnabled(list.id, !list.remindersEnabled); + } on Object { + if (context.mounted) { + _showCalendarMutationFailure(context, context.l10n.operationFailed); + } + } +} + +Future _changeCalendarColor( + BuildContext context, + WidgetRef ref, + CalendarSourceEntity source, +) async { + final choice = await showCalendarColorDialog( + context, + provider: source.provider, + currentBackgroundColor: source.backgroundColor, + currentColorId: source.colorId, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (!context.mounted || choice == null) return; + try { + await ref + .read(calendarRepositoryProvider) + .setSourceColor(source.id, choice); + _requestCalendarMutationSync(ref, source.accountId); + } on Object catch (error) { + if (context.mounted) { + _showCalendarMutationFailure( + context, + context.l10n.calendarUpdateFailed( + syncFailureMessage( + error, + networkUnavailableMessage: context.l10n.networkOfflineTryAgain, + ), + ), + ); + } + } +} + Future _renameCalendar( BuildContext context, WidgetRef ref, @@ -808,9 +1355,13 @@ Future _renameCalendar( ) async { final title = await showBusyMaxTextPrompt( context, - title: context.l10n.rename, + title: source.capabilities.renameMode == CalendarRenameMode.personal + ? context.l10n.setCustomCalendarName + : context.l10n.rename, label: context.l10n.title, - actionLabel: context.l10n.rename, + actionLabel: source.capabilities.renameMode == CalendarRenameMode.personal + ? context.l10n.setAction + : context.l10n.rename, initialValue: source.summary, headerBarService: ref.read(linuxHeaderBarServiceProvider), ); @@ -820,9 +1371,24 @@ Future _renameCalendar( title.trim() == source.summary) { return; } - await ref - .read(calendarRepositoryProvider) - .renameLocalSource(source.id, title.trim()); + try { + await ref + .read(calendarRepositoryProvider) + .renameLocalSource(source.id, title.trim()); + _requestCalendarMutationSync(ref, source.accountId); + } on Object catch (error) { + if (context.mounted) { + _showCalendarMutationFailure( + context, + context.l10n.calendarUpdateFailed( + syncFailureMessage( + error, + networkUnavailableMessage: context.l10n.networkOfflineTryAgain, + ), + ), + ); + } + } } Future _deleteCalendar( @@ -830,18 +1396,53 @@ Future _deleteCalendar( WidgetRef ref, CalendarSourceEntity source, ) async { + final removeFromList = + source.capabilities.removalMode == CalendarRemovalMode.removeFromList; final confirmed = await showBusyMaxConfirm( context, - title: context.l10n.delete, - message: context.l10n.deleteCalendarConfirmation(source.summary), - confirmLabel: context.l10n.delete, + title: removeFromList + ? context.l10n.removeFromMyCalendars + : context.l10n.delete, + message: removeFromList + ? context.l10n.removeCalendarConfirmation(source.summary) + : context.l10n.deleteCalendarConfirmation(source.summary), + confirmLabel: removeFromList + ? context.l10n.removeAction + : context.l10n.delete, destructive: true, headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!confirmed) { return; } - await ref.read(calendarRepositoryProvider).deleteLocalSource(source.id); + try { + await ref.read(calendarRepositoryProvider).deleteLocalSource(source.id); + _requestCalendarMutationSync(ref, source.accountId); + } on Object catch (error) { + if (context.mounted) { + final message = + error is CalendarMutationNotAllowed && + error.reason == CalendarMutationDenialReason.pendingChanges + ? context.l10n.calendarPendingChangesPreventRemoval + : context.l10n.calendarDeleteFailed( + syncFailureMessage( + error, + networkUnavailableMessage: context.l10n.networkOfflineTryAgain, + ), + ); + _showCalendarMutationFailure(context, message); + } + } +} + +void _requestCalendarMutationSync(WidgetRef ref, String accountId) { + ref + .read(pendingCalendarMutationSyncRequesterForAccountProvider(accountId)) + .request(); +} + +void _showCalendarMutationFailure(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); } Future _renameTaskList( @@ -928,3 +1529,117 @@ Future _deleteTaskList( } } } + +Future _subscriptionForSource( + WidgetRef ref, + CalendarSourceEntity source, +) async { + final subscriptions = await ref + .read(webCalSubscriptionServiceProvider) + .listSubscriptions(); + return subscriptions + .where((subscription) => subscription.calendarSourceId == source.id) + .firstOrNull; +} + +Future _refreshSubscriptionSource( + BuildContext context, + WidgetRef ref, + CalendarSourceEntity source, +) async { + try { + final subscription = await _subscriptionForSource(ref, source); + if (subscription == null) return; + await ref + .read(webCalSubscriptionServiceProvider) + .refreshSubscription(subscription.id, force: true); + } on Object catch (error) { + if (context.mounted) _showWebCalError(context, error); + } +} + +Future _renameSubscriptionSource( + BuildContext context, + WidgetRef ref, + CalendarSourceEntity source, +) async { + final name = await showBusyMaxTextPrompt( + context, + title: context.l10n.rename, + label: context.l10n.subscriptionName, + actionLabel: context.l10n.rename, + initialValue: source.summary, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (name == null || !context.mounted) return; + try { + final subscription = await _subscriptionForSource(ref, source); + if (subscription == null) return; + await ref + .read(webCalSubscriptionServiceProvider) + .renameSubscription(subscription.id, name); + } on Object catch (error) { + if (context.mounted) _showWebCalError(context, error); + } +} + +Future _colorSubscriptionSource( + BuildContext context, + WidgetRef ref, + CalendarSourceEntity source, +) async { + final color = await showBusyMaxTextPrompt( + context, + title: context.l10n.calendarColor, + label: context.l10n.subscriptionColor, + actionLabel: context.l10n.save, + initialValue: source.backgroundColor, + message: context.l10n.subscriptionColorHelp, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (color == null || !context.mounted) return; + try { + final subscription = await _subscriptionForSource(ref, source); + if (subscription == null) return; + await ref + .read(webCalSubscriptionServiceProvider) + .changeSubscriptionColor(subscription.id, color); + } on Object catch (error) { + if (context.mounted) _showWebCalError(context, error); + } +} + +Future _unsubscribeSource( + BuildContext context, + WidgetRef ref, + CalendarSourceEntity source, +) async { + final confirmed = await showBusyMaxConfirm( + context, + title: context.l10n.unsubscribeCalendarTitle(source.summary), + message: context.l10n.unsubscribeCalendarConfirmation, + confirmLabel: context.l10n.unsubscribe, + destructive: true, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (!confirmed || !context.mounted) return; + try { + final subscription = await _subscriptionForSource(ref, source); + if (subscription == null) return; + await ref + .read(webCalSubscriptionServiceProvider) + .unsubscribe(subscription.id); + } on Object catch (error) { + if (context.mounted) _showWebCalError(context, error); + } +} + +void _showWebCalError(BuildContext context, Object error) { + final code = switch (error) { + WebCalSubscriptionException(:final code) => code, + _ => 'WebCalOperationFailed', + }; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.subscriptionOperationFailed(code))), + ); +} diff --git a/lib/src/features/schedule/presentation/schedule_toolbar.dart b/lib/src/features/schedule/presentation/schedule_toolbar.dart index 914c92a2..9873646d 100644 --- a/lib/src/features/schedule/presentation/schedule_toolbar.dart +++ b/lib/src/features/schedule/presentation/schedule_toolbar.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; @@ -130,7 +129,13 @@ class ScheduleToolbar extends StatelessWidget { Expanded( child: _fittingRangeTitle( context, - _rangeLabel(context, mode, range, selectedDate), + localizedScheduleHeading( + Localizations.localeOf(context).toLanguageTag(), + mode, + range, + selectedDate, + agendaLabel: context.l10n.viewAgenda, + ), ), ), BusyMaxMenuButton( @@ -145,6 +150,7 @@ class ScheduleToolbar extends StatelessWidget { value: value, label: _modeLabel(context, value), icon: _modeIcon(value), + role: BusyMaxMenuEntryRole.radio, selected: mode == value, shortcut: BusyMaxShortcutLabels.forViewMode(value), ), @@ -271,22 +277,6 @@ IconData _modeIcon(ScheduleViewMode mode) { }; } -String _rangeLabel( - BuildContext context, - ScheduleViewMode mode, - ScheduleRange range, - DateTime selectedDate, -) { - final locale = Localizations.localeOf(context).toLanguageTag(); - return switch (mode) { - ScheduleViewMode.day => DateFormat.yMMMMEEEEd(locale).format(selectedDate), - ScheduleViewMode.month => DateFormat.yMMMM(locale).format(selectedDate), - ScheduleViewMode.year => DateFormat.y(locale).format(selectedDate), - ScheduleViewMode.agenda => context.l10n.viewAgenda, - ScheduleViewMode.week => localizedScheduleRangeLabel(locale, range), - }; -} - String _modeLabel(BuildContext context, ScheduleViewMode mode) { return switch (mode) { ScheduleViewMode.day => context.l10n.viewDay, diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 350822f0..d6a3cc10 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -5,7 +5,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:intl/intl.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../app/app_bootstrap.dart'; @@ -26,6 +25,7 @@ import '../../../features/sync/sync_auth_error.dart'; import '../../../l10n/l10n.dart'; import '../../../l10n/localized_formatters.dart'; import '../../../platform/linux_header_bar_service.dart'; +import '../../../platform/linux_header_bar_provider.dart'; import '../../../schedule/schedule_commands.dart'; import '../../../schedule/schedule_filters.dart'; import '../../../schedule/schedule_item.dart'; @@ -679,11 +679,12 @@ class _ScheduleWorkspaceState extends ConsumerState { if (!_nativeHeaderBarAvailable) { return; } - final titleRange = _scheduleRangeLabel( - context, + final titleRange = localizedScheduleHeading( + Localizations.localeOf(context).toLanguageTag(), _mode, range, _selectedDate, + agendaLabel: context.l10n.viewAgenda, ); final sidebarVisible = showSidebar && !_sidebarCollapsed; final headerBarState = BusyMaxHeaderBarState( @@ -1011,7 +1012,7 @@ class _ScheduleWorkspaceState extends ConsumerState { List accounts, ) async { final allLists = []; - for (final account in accounts) { + for (final account in accounts.where((account) => account.isTaskCapable)) { final repository = ref.read( taskListsRepositoryForAccountProvider(account.id), ); @@ -1142,15 +1143,18 @@ class _ScheduleWorkspaceState extends ConsumerState { }); } - void _setMode(ScheduleViewMode mode) { + void _setMode(ScheduleViewMode mode, {DateTime? agendaDate}) { if (_mode == mode) { + if (mode == ScheduleViewMode.agenda && agendaDate != null) { + _setDate(agendaDate); + } return; } setState(() { _mode = mode; _lastSettingsMode = mode; if (mode == ScheduleViewMode.agenda) { - _selectedDate = _day(DateTime.now()); + _selectedDate = _day(agendaDate ?? DateTime.now()); _resetAgendaLoadedDays(); } if (_scope == ScheduleScope.today || _scope == ScheduleScope.upcoming) { @@ -1897,30 +1901,39 @@ class _ScheduleWorkspaceState extends ConsumerState { Future _saveEvent( EventEditorDraft draft, { + String? originalAccountId, CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, }) async { await ref .read(calendarRepositoryProvider) .updateLocalEvent(draft, guestUpdatePolicy: guestUpdatePolicy); - _requestCalendarMutationSync(draft.accountId); + final sourceAccountId = originalAccountId; + if (sourceAccountId != null && sourceAccountId != draft.accountId) { + unawaited(_syncMovedEvent(draft.accountId, sourceAccountId)); + } else { + _requestCalendarMutationSync(draft.accountId); + } if (mounted) { setState(() {}); } } void _requestCalendarMutationSync(String accountId) { - unawaited(_syncCalendarMutation(accountId)); + unawaited(() async { + await _syncCalendarMutation(accountId); + }()); } - Future _syncCalendarMutation(String accountId) async { + Future _syncCalendarMutation(String accountId) async { try { await ref .read(accountSyncOperationsProvider) .syncCalendar(accountId, full: false); + return true; } on Object catch (error) { if (!mounted) { - return; + return false; } ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -1934,6 +1947,17 @@ class _ScheduleWorkspaceState extends ConsumerState { ), ), ); + return false; + } + } + + Future _syncMovedEvent( + String destinationAccountId, + String sourceAccountId, + ) async { + final destinationSynced = await _syncCalendarMutation(destinationAccountId); + if (destinationSynced) { + await _syncCalendarMutation(sourceAccountId); } } @@ -1967,7 +1991,11 @@ class _ScheduleWorkspaceState extends ConsumerState { } final savedDraft = result.draft; if (savedDraft != null) { - await _saveEvent(savedDraft, guestUpdatePolicy: result.guestUpdatePolicy); + await _saveEvent( + savedDraft, + originalAccountId: draft.accountId, + guestUpdatePolicy: result.guestUpdatePolicy, + ); } } @@ -1995,11 +2023,8 @@ class _ScheduleWorkspaceState extends ConsumerState { return; } RecurringEventMutationScope? recurringScope; - if (item is CalendarScheduleItem && - item.providerRecurringEventId != null && - (item.provider == BusyProvider.appleICloud || - item.provider == BusyProvider.nextcloud)) { - recurringScope = await _chooseRecurringEventMutationScope(); + if (item is CalendarScheduleItem && item.providerRecurringEventId != null) { + recurringScope = await _chooseRecurringEventMutationScope(item.provider); if (recurringScope == null || !mounted) return; } var guestUpdatePolicy = CalendarGuestUpdatePolicy.send; @@ -2088,7 +2113,10 @@ class _ScheduleWorkspaceState extends ConsumerState { } } - Future _chooseRecurringEventMutationScope() { + Future _chooseRecurringEventMutationScope( + BusyProvider provider, + ) { + final supportsFollowing = supportsThisAndFollowingEventMutation(provider); return showBusyMaxModalEditorDialog( context, headerBarService: ref.read(linuxHeaderBarServiceProvider), @@ -2103,25 +2131,37 @@ class _ScheduleWorkspaceState extends ConsumerState { BusyMaxGroupedList( filled: true, children: [ - BusyMaxActionRow( - title: context.l10n.entireSeries, - subtitle: context.l10n.chooseRecurringEventScope, - leading: const Icon(Icons.repeat), - onTap: () => Navigator.of( - dialogContext, - ).pop(RecurringEventMutationScope.entireSeries), - ), BusyMaxActionRow( title: context.l10n.singleOccurrence, + subtitle: context.l10n.chooseRecurringEventScope, leading: const Icon(Icons.event_outlined), onTap: () => Navigator.of( dialogContext, ).pop(RecurringEventMutationScope.singleOccurrence), ), BusyMaxActionRow( - title: context.l10n.thisAndFutureUnavailable, - leading: const Icon(Icons.update_disabled_outlined), - enabled: false, + title: context.l10n.thisAndFollowingEvents, + subtitle: supportsFollowing + ? null + : context.l10n.thisAndFutureUnavailable, + leading: Icon( + supportsFollowing + ? Icons.update_outlined + : Icons.update_disabled_outlined, + ), + enabled: supportsFollowing, + onTap: supportsFollowing + ? () => Navigator.of( + dialogContext, + ).pop(RecurringEventMutationScope.thisAndFuture) + : null, + ), + BusyMaxActionRow( + title: context.l10n.entireSeries, + leading: const Icon(Icons.repeat), + onTap: () => Navigator.of( + dialogContext, + ).pop(RecurringEventMutationScope.entireSeries), ), ], ), @@ -2254,7 +2294,7 @@ class _ScheduleWorkspaceState extends ConsumerState { try { final accounts = await ref .read(accountsRepositoryProvider) - .listSignedInAccounts(); + .listSyncEligibleAccounts(); if (accounts.isEmpty) { return; } @@ -2306,7 +2346,7 @@ class _ScheduleWorkspaceState extends ConsumerState { case ScheduleWorkspaceCommandKind.today: _goToToday(); case ScheduleWorkspaceCommandKind.agenda: - _setMode(ScheduleViewMode.agenda); + _setMode(ScheduleViewMode.agenda, agendaDate: command.date); case ScheduleWorkspaceCommandKind.newEvent: unawaited(_openNewEvent(sources, _selectedDate)); case ScheduleWorkspaceCommandKind.newTask: @@ -2832,19 +2872,3 @@ int _firstWeekday(BuildContext context) { } DateTime _day(DateTime date) => DateTime(date.year, date.month, date.day); - -String _scheduleRangeLabel( - BuildContext context, - ScheduleViewMode mode, - ScheduleRange range, - DateTime selectedDate, -) { - final locale = Localizations.localeOf(context).toLanguageTag(); - return switch (mode) { - ScheduleViewMode.day => DateFormat.yMMMMEEEEd(locale).format(selectedDate), - ScheduleViewMode.month => DateFormat.yMMMM(locale).format(selectedDate), - ScheduleViewMode.year => DateFormat.y(locale).format(selectedDate), - ScheduleViewMode.agenda => context.l10n.viewAgenda, - ScheduleViewMode.week => localizedScheduleRangeLabel(locale, range), - }; -} diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index e2192d5e..fed6e0e4 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -25,10 +25,14 @@ import 'package:busymax/src/core/auth/oauth_models.dart'; import '../../../l10n/app_locale.dart'; import '../../../l10n/l10n.dart'; import '../../../platform/linux_header_bar_service.dart'; +import '../../../platform/linux_header_bar_provider.dart'; +import '../../../webcal/webcal_subscription_service.dart'; +import '../../../webcal/webcal_uri.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import '../../accounts/data/accounts_repository.dart'; import '../../accounts/domain/account_connection_state.dart'; import '../../auth/data/auth_repository.dart'; +import '../../calendar/presentation/ical_import_flow.dart'; import '../../connectivity/network_connectivity_service.dart'; import '../../diagnostics/presentation/diagnostics_screen.dart'; import '../../feedback/presentation/feedback_dialog.dart'; @@ -40,7 +44,7 @@ final _settingsLogger = RedactingLogger(Logger('SettingsScreen')); const _systemLocaleTag = 'system'; class SettingsScreen extends ConsumerStatefulWidget { - const SettingsScreen({super.key, this.initialPage = SettingsPage.accounts}); + const SettingsScreen({super.key, this.initialPage = SettingsPage.system}); final SettingsPage initialPage; @@ -57,6 +61,7 @@ class _SettingsScreenState extends ConsumerState { BusyProvider? _connectingProvider; DavCancellationToken? _davCancellation; final _removingAccountIds = {}; + final _busySubscriptionIds = {}; @override void initState() { @@ -93,6 +98,8 @@ class _SettingsScreenState extends ConsumerState { ref.watch(davCollectionsStreamProvider).valueOrNull ?? const []; final davConflicts = ref.watch(davConflictsStreamProvider).valueOrNull ?? const []; + final subscriptions = + ref.watch(webCalSubscriptionsProvider).valueOrNull ?? const []; final config = ref.watch(buildConfigProvider); final settings = ref.watch(appSettingsControllerProvider); final launchAtLogin = ref.watch(launchAtLoginEnabledProvider); @@ -116,8 +123,6 @@ class _SettingsScreenState extends ConsumerState { onCancelConnection: _cancelAccountConnection, onReconnect: (account) => unawaited(_connectAccount(account.provider, reconnecting: account)), - onCreateTaskList: (accountId) => - _createTaskList(context, ref, accountId), removingAccountIds: _removingAccountIds, onRemoveAccount: (account) => unawaited(_removeAccount(context, ref, account)), @@ -137,6 +142,17 @@ class _SettingsScreenState extends ConsumerState { ), onResolveConflict: (conflict, resolution) => unawaited(_resolveConflict(conflict, resolution)), + onImportIcs: () => unawaited(showIcsImportFlow(context, ref)), + subscriptions: subscriptions, + busySubscriptionIds: _busySubscriptionIds, + onAddSubscription: () => unawaited(_addSubscription()), + onRefreshSubscription: (subscription) => + unawaited(_refreshSubscription(subscription)), + onRenameSubscription: (subscription) => + unawaited(_renameSubscription(subscription)), + onChangeSubscriptionColor: (subscription) => + unawaited(_changeSubscriptionColor(subscription)), + onUnsubscribe: (subscription) => unawaited(_unsubscribe(subscription)), ), SettingsPage.schedule => BusyMaxGroupedList( title: l10n.scheduleDisplaySettings, @@ -162,16 +178,23 @@ class _SettingsScreenState extends ConsumerState { ], ), SettingsPage.system => BusyMaxGroupedList( - title: l10n.themeSystem, + title: l10n.settingsSystem, filled: true, children: [ - BusyMaxActionRow( - title: l10n.manualFullSync, - leading: const Icon(YaruIcons.sync), - enabled: accounts.isNotEmpty, - onTap: accounts.isEmpty - ? null - : () => _fullSync(context, ref, accounts), + BusyMaxComboRow( + title: l10n.currentLocale, + leading: const Icon(Icons.language), + values: [ + _systemLocaleTag, + for (final option in busyMaxLocaleOptions) option.tag, + ], + selected: settings.localeTag ?? _systemLocaleTag, + labelFor: (tag) => tag == _systemLocaleTag + ? l10n.themeSystem + : busyMaxLocaleEndonym(tag), + onSelected: (tag) => settingsController.setLocaleTag( + tag == _systemLocaleTag ? null : tag, + ), ), BusyMaxSwitchRow( title: l10n.showTrayIcon, @@ -212,21 +235,6 @@ class _SettingsScreenState extends ConsumerState { labelFor: (value) => _themeModeLabel(context, value), onSelected: themeController.setThemeMode, ), - BusyMaxComboRow( - title: l10n.currentLocale, - leading: const Icon(Icons.language), - values: [ - _systemLocaleTag, - for (final option in busyMaxLocaleOptions) option.tag, - ], - selected: settings.localeTag ?? _systemLocaleTag, - labelFor: (tag) => tag == _systemLocaleTag - ? l10n.themeSystem - : busyMaxLocaleEndonym(tag), - onSelected: (tag) => settingsController.setLocaleTag( - tag == _systemLocaleTag ? null : tag, - ), - ), ], ), SettingsPage.notifications => BusyMaxGroupedList( @@ -314,7 +322,30 @@ class _SettingsScreenState extends ConsumerState { ), ], ), - SettingsPage.diagnostics => const DiagnosticsPanel(scrollable: false), + SettingsPage.diagnostics => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.sync, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: BusyMaxSpacing.sm), + Text(l10n.forceFullResyncDescription), + BusyMaxGroupedList( + key: const ValueKey('diagnostics-full-resync-section'), + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.forceFullResync, + leading: const Icon(YaruIcons.sync), + enabled: accounts.isNotEmpty, + onTap: accounts.isEmpty + ? null + : () => _forceFullResync(context, ref, accounts), + ), + ], + ), + const SizedBox(height: BusyMaxSpacing.lg), + const DiagnosticsPanel(scrollable: false), + ], + ), }; return Scaffold( @@ -378,6 +409,102 @@ class _SettingsScreenState extends ConsumerState { ); } + Future _addSubscription({String? initialUrl}) async { + await showAddCalendarSubscriptionFlow(context, ref, initialUrl: initialUrl); + } + + Future _refreshSubscription( + WebCalSubscriptionEntity subscription, + ) async { + await _runSubscriptionOperation(subscription.id, () async { + await ref + .read(webCalSubscriptionServiceProvider) + .refreshSubscription(subscription.id, force: true); + }); + } + + Future _renameSubscription( + WebCalSubscriptionEntity subscription, + ) async { + final value = await showBusyMaxTextPrompt( + context, + title: context.l10n.rename, + label: context.l10n.subscriptionName, + actionLabel: context.l10n.rename, + initialValue: subscription.name, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (value == null || !mounted) return; + await _runSubscriptionOperation(subscription.id, () async { + await ref + .read(webCalSubscriptionServiceProvider) + .renameSubscription(subscription.id, value); + }); + } + + Future _changeSubscriptionColor( + WebCalSubscriptionEntity subscription, + ) async { + final value = await showBusyMaxTextPrompt( + context, + title: context.l10n.calendarColor, + label: context.l10n.subscriptionColor, + actionLabel: context.l10n.save, + initialValue: subscription.color, + message: context.l10n.subscriptionColorHelp, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (value == null || !mounted) return; + await _runSubscriptionOperation(subscription.id, () async { + await ref + .read(webCalSubscriptionServiceProvider) + .changeSubscriptionColor(subscription.id, value); + }); + } + + Future _unsubscribe(WebCalSubscriptionEntity subscription) async { + final confirmed = await showBusyMaxConfirm( + context, + title: context.l10n.unsubscribeCalendarTitle(subscription.name), + message: context.l10n.unsubscribeCalendarConfirmation, + confirmLabel: context.l10n.unsubscribe, + destructive: true, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (!confirmed || !mounted) return; + await _runSubscriptionOperation(subscription.id, () async { + await ref + .read(webCalSubscriptionServiceProvider) + .unsubscribe(subscription.id); + }); + } + + Future _runSubscriptionOperation( + String subscriptionId, + Future Function() operation, + ) async { + if (_busySubscriptionIds.contains(subscriptionId)) return; + setState(() => _busySubscriptionIds.add(subscriptionId)); + try { + await operation(); + } on Object catch (error) { + if (mounted) _showSubscriptionError(error); + } finally { + if (mounted) setState(() => _busySubscriptionIds.remove(subscriptionId)); + } + } + + void _showSubscriptionError(Object error) { + final code = switch (error) { + WebCalSubscriptionException(:final code) => code, + WebCalUriException(:final code) => code, + _ => 'WebCalOperationFailed', + }; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.subscriptionOperationFailed(code))), + ); + } + Future _setLaunchAtLogin(BuildContext context, bool enabled) async { try { await ref.read(linuxAutostartServiceProvider).setEnabled(enabled); @@ -433,7 +560,7 @@ class _SettingsScreenState extends ConsumerState { return; } if (action == BusyMaxHeaderBarAction.settings) { - _selectPage(SettingsPage.accounts); + _selectPage(SettingsPage.system); return; } if (action == BusyMaxHeaderBarAction.keyboardShortcuts) { @@ -597,6 +724,10 @@ class _SettingsScreenState extends ConsumerState { enteredServer: nextcloudServer!, cancellationToken: cancellation, )).accountId; + case BusyProvider.webCal: + throw StateError( + 'WebCal subscriptions use the calendar subscription dialog.', + ); } if (accountId != null) { unawaited(_syncConnectedAccount(runSync, accountId)); @@ -707,40 +838,7 @@ class _SettingsScreenState extends ConsumerState { } } - Future _createTaskList( - BuildContext context, - WidgetRef ref, - String accountId, - ) async { - final title = await _taskListTitleDialog( - context, - ref.read(linuxHeaderBarServiceProvider), - ); - if (title == null || title.trim().isEmpty) { - return; - } - - try { - await ref - .read(taskListsRepositoryForAccountProvider(accountId)) - .createTaskList(title.trim()); - } on Object catch (error) { - _settingsLogger.warning('Task-list creation failed: $error'); - if (context.mounted) { - _showMessage( - context, - context.l10n.taskListCreateFailed( - syncFailureMessage( - error, - networkUnavailableMessage: context.l10n.networkOfflineTryAgain, - ), - ), - ); - } - } - } - - Future _fullSync( + Future _forceFullResync( BuildContext context, WidgetRef ref, List accounts, @@ -864,6 +962,7 @@ class _SettingsPageSelector extends StatelessWidget { value: page, label: _settingsPageLabel(context, page), icon: _settingsPageIcon(page), + role: BusyMaxMenuEntryRole.radio, selected: page == selected, ), ], @@ -935,9 +1034,9 @@ class _SettingsFallbackHeader extends StatelessWidget { } enum SettingsPage { + system, accounts, schedule, - system, notifications, privacy, diagnostics, @@ -945,12 +1044,13 @@ enum SettingsPage { SettingsPage settingsPageFromRouteValue(String? value) { return switch (value) { + 'accounts' => SettingsPage.accounts, 'schedule' => SettingsPage.schedule, 'system' => SettingsPage.system, 'notifications' => SettingsPage.notifications, 'privacy' => SettingsPage.privacy, 'diagnostics' => SettingsPage.diagnostics, - _ => SettingsPage.accounts, + _ => SettingsPage.system, }; } @@ -961,7 +1061,7 @@ String _settingsPageLabel(BuildContext context, SettingsPage page) { return switch (page) { SettingsPage.accounts => l10n.accounts, SettingsPage.schedule => l10n.scheduleSettings, - SettingsPage.system => l10n.themeSystem, + SettingsPage.system => l10n.settingsSystem, SettingsPage.notifications => l10n.notifications, SettingsPage.privacy => l10n.privacy, SettingsPage.diagnostics => l10n.diagnostics, @@ -1004,19 +1104,6 @@ String _timeOfDayLabel(BuildContext context, int minute) { ); } -Future _taskListTitleDialog( - BuildContext context, - LinuxHeaderBarService headerBarService, -) { - return showBusyMaxTextPrompt( - context, - title: context.l10n.newTaskList, - label: context.l10n.title, - actionLabel: context.l10n.create, - headerBarService: headerBarService, - ); -} - class _AccountManagementSection extends StatelessWidget { const _AccountManagementSection({ required this.accounts, @@ -1029,7 +1116,6 @@ class _AccountManagementSection extends StatelessWidget { required this.onAddNextcloud, required this.onCancelConnection, required this.onReconnect, - required this.onCreateTaskList, required this.removingAccountIds, required this.onRemoveAccount, required this.davCollections, @@ -1038,6 +1124,14 @@ class _AccountManagementSection extends StatelessWidget { required this.onEventsSelected, required this.onTasksSelected, required this.onResolveConflict, + required this.onImportIcs, + required this.subscriptions, + required this.busySubscriptionIds, + required this.onAddSubscription, + required this.onRefreshSubscription, + required this.onRenameSubscription, + required this.onChangeSubscriptionColor, + required this.onUnsubscribe, }); final List accounts; @@ -1050,7 +1144,6 @@ class _AccountManagementSection extends StatelessWidget { final VoidCallback onAddNextcloud; final VoidCallback onCancelConnection; final void Function(AccountEntity account) onReconnect; - final void Function(String accountId) onCreateTaskList; final Set removingAccountIds; final void Function(AccountEntity account) onRemoveAccount; final List davCollections; @@ -1065,6 +1158,17 @@ class _AccountManagementSection extends StatelessWidget { DavConflictResolution resolution, ) onResolveConflict; + final VoidCallback onImportIcs; + final List subscriptions; + final Set busySubscriptionIds; + final VoidCallback onAddSubscription; + final void Function(WebCalSubscriptionEntity subscription) + onRefreshSubscription; + final void Function(WebCalSubscriptionEntity subscription) + onRenameSubscription; + final void Function(WebCalSubscriptionEntity subscription) + onChangeSubscriptionColor; + final void Function(WebCalSubscriptionEntity subscription) onUnsubscribe; @override Widget build(BuildContext context) { @@ -1128,12 +1232,6 @@ class _AccountManagementSection extends StatelessWidget { onReconnect: connecting || removingAccountIds.contains(account.id) ? null : () => onReconnect(account), - onCreateTaskList: - account.provider == BusyProvider.google || - account.provider == BusyProvider.microsoft || - account.provider == BusyProvider.nextcloud - ? () => onCreateTaskList(account.id) - : null, onRefreshCollections: account.provider == BusyProvider.appleICloud || account.provider == BusyProvider.nextcloud @@ -1162,17 +1260,349 @@ class _AccountManagementSection extends StatelessWidget { onResolve: onResolveConflict, ), ], + _CalendarImportCard(onImport: onImportIcs), + _CalendarSubscriptionsCard( + subscriptions: subscriptions, + busySubscriptionIds: busySubscriptionIds, + onAdd: onAddSubscription, + onRefresh: onRefreshSubscription, + onRename: onRenameSubscription, + onChangeColor: onChangeSubscriptionColor, + onUnsubscribe: onUnsubscribe, + ), + ], + ); + } +} + +class _CalendarImportCard extends StatelessWidget { + const _CalendarImportCard({required this.onImport}); + + final VoidCallback onImport; + + @override + Widget build(BuildContext context) { + return BusyMaxGroupedList( + title: context.l10n.calendarImport, + description: context.l10n.calendarImportDescription, + filled: true, + children: [ + BusyMaxActionRow( + key: const ValueKey('import-ics-file'), + title: context.l10n.importIcsFile, + leading: const Icon(Icons.file_open_outlined), + onTap: onImport, + ), + ], + ); + } +} + +class _CalendarSubscriptionsCard extends StatelessWidget { + const _CalendarSubscriptionsCard({ + required this.subscriptions, + required this.busySubscriptionIds, + required this.onAdd, + required this.onRefresh, + required this.onRename, + required this.onChangeColor, + required this.onUnsubscribe, + }); + + final List subscriptions; + final Set busySubscriptionIds; + final VoidCallback onAdd; + final void Function(WebCalSubscriptionEntity) onRefresh; + final void Function(WebCalSubscriptionEntity) onRename; + final void Function(WebCalSubscriptionEntity) onChangeColor; + final void Function(WebCalSubscriptionEntity) onUnsubscribe; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BusyMaxGroupedList( + title: l10n.calendarSubscriptions, + description: l10n.calendarSubscriptionsDescription, + filled: true, + children: [ + BusyMaxActionRow( + key: const ValueKey('add-calendar-subscription'), + title: l10n.addCalendarSubscription, + leading: const Icon(YaruIcons.plus), + onTap: onAdd, + ), + ], + ), + for (final subscription in subscriptions) + BusyMaxGroupedList( + key: ValueKey('calendar-subscription-${subscription.id}'), + title: subscription.name, + description: subscription.safeOrigin, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.subscriptionReadOnly, + subtitle: _subscriptionTiming(context, subscription), + leading: _DavCollectionIndicator(color: subscription.color), + ), + BusyMaxActionRow( + title: subscription.lastFailureCode == null + ? l10n.subscriptionStatusHealthy + : l10n.subscriptionStatusIssue( + subscription.lastFailureCode!, + ), + subtitle: l10n.subscriptionNextRefresh( + _formatDavDateTime(context, subscription.nextRefreshAtUtc), + ), + leading: Icon( + subscription.lastFailureCode == null + ? YaruIcons.checkmark + : YaruIcons.warning, + ), + ), + BusyMaxActionRow( + title: l10n.refreshNow, + leading: const Icon(YaruIcons.sync), + onTap: busySubscriptionIds.contains(subscription.id) + ? null + : () => onRefresh(subscription), + ), + BusyMaxActionRow( + title: l10n.rename, + leading: const Icon(Icons.edit_outlined), + onTap: busySubscriptionIds.contains(subscription.id) + ? null + : () => onRename(subscription), + ), + BusyMaxActionRow( + title: l10n.calendarColor, + leading: const Icon(Icons.palette_outlined), + onTap: busySubscriptionIds.contains(subscription.id) + ? null + : () => onChangeColor(subscription), + ), + BusyMaxActionRow( + title: l10n.unsubscribe, + leading: Icon( + YaruIcons.trash, + color: Theme.of(context).colorScheme.error, + ), + destructive: true, + onTap: busySubscriptionIds.contains(subscription.id) + ? null + : () => onUnsubscribe(subscription), + ), + ], + ), + ], + ); + } +} + +String _subscriptionTiming( + BuildContext context, + WebCalSubscriptionEntity subscription, +) { + final last = subscription.lastSuccessfulSyncAtUtc; + return last == null + ? context.l10n.subscriptionNeverRefreshed + : context.l10n.subscriptionLastRefresh(_formatDavDateTime(context, last)); +} + +final class _WebCalAddInput { + const _WebCalAddInput({ + required this.url, + required this.name, + required this.color, + required this.refreshMode, + }); + + final String url; + final String? name; + final String? color; + final WebCalRefreshMode refreshMode; +} + +class _WebCalAddDialog extends StatefulWidget { + const _WebCalAddDialog({this.initialUrl}); + + final String? initialUrl; + + @override + State<_WebCalAddDialog> createState() => _WebCalAddDialogState(); +} + +class _WebCalAddDialogState extends State<_WebCalAddDialog> { + late final TextEditingController _urlController; + late final TextEditingController _nameController; + late final TextEditingController _colorController; + var _refreshMode = WebCalRefreshMode.automatic; + + @override + void initState() { + super.initState(); + _urlController = TextEditingController(text: widget.initialUrl) + ..addListener(_changed); + _nameController = TextEditingController(); + _colorController = TextEditingController()..addListener(_changed); + } + + @override + void dispose() { + _urlController + ..removeListener(_changed) + ..dispose(); + _nameController.dispose(); + _colorController + ..removeListener(_changed) + ..dispose(); + super.dispose(); + } + + void _changed() => setState(() {}); + + NormalizedWebCalUri? get _normalized { + try { + return normalizeWebCalUri(_urlController.text); + } on FormatException { + return null; + } + } + + bool get _colorValid { + final value = _colorController.text.trim(); + return value.isEmpty || RegExp(r'^#[0-9A-Fa-f]{6}$').hasMatch(value); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final normalized = _normalized; + return BusyMaxDialogShell( + title: l10n.addCalendarSubscription, + maxWidth: BusyMaxSizes.compactDetailsWidth, + actions: [ + BusyMaxPushButton.standard( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + BusyMaxPushButton.suggested( + key: const ValueKey('confirm-add-subscription'), + onPressed: normalized == null || !_colorValid + ? null + : () => Navigator.of(context).pop( + _WebCalAddInput( + url: _urlController.text, + name: _nameController.text.trim().isEmpty + ? null + : _nameController.text.trim(), + color: _colorController.text.trim().isEmpty + ? null + : _colorController.text.trim(), + refreshMode: _refreshMode, + ), + ), + child: Text(l10n.addSubscriptionAction), + ), + ], + children: [ + TextField( + key: const ValueKey('subscription-url'), + controller: _urlController, + autofocus: true, + decoration: InputDecoration( + labelText: l10n.subscriptionUrl, + helperText: l10n.subscriptionUrlHelp, + errorText: + _urlController.text.trim().isNotEmpty && normalized == null + ? l10n.subscriptionUrlInvalid + : null, + ), + ), + const SizedBox(height: BusyMaxSpacing.md), + TextField( + controller: _nameController, + decoration: InputDecoration(labelText: l10n.subscriptionName), + ), + const SizedBox(height: BusyMaxSpacing.md), + TextField( + controller: _colorController, + decoration: InputDecoration( + labelText: l10n.subscriptionColor, + helperText: l10n.subscriptionColorHelp, + errorText: _colorValid ? null : l10n.subscriptionColorInvalid, + ), + ), + const SizedBox(height: BusyMaxSpacing.md), + BusyMaxComboRow( + title: l10n.subscriptionRefreshMode, + values: WebCalRefreshMode.values, + selected: _refreshMode, + labelFor: (mode) => _refreshModeLabel(context, mode), + onSelected: (value) => setState(() => _refreshMode = value), + ), + const SizedBox(height: BusyMaxSpacing.md), + Text( + normalized == null + ? l10n.subscriptionSafeOriginUnavailable + : l10n.subscriptionSafeOrigin(normalized.safeOrigin.toString()), + style: Theme.of(context).textTheme.bodySmall, + ), ], ); } } +String _refreshModeLabel(BuildContext context, WebCalRefreshMode mode) => + switch (mode) { + WebCalRefreshMode.automatic => context.l10n.subscriptionAutomatic, + WebCalRefreshMode.hourly => context.l10n.subscriptionHourly, + WebCalRefreshMode.sixHours => context.l10n.subscriptionSixHours, + WebCalRefreshMode.daily => context.l10n.subscriptionDaily, + }; + +Future showAddCalendarSubscriptionFlow( + BuildContext context, + WidgetRef ref, { + String? initialUrl, +}) async { + final input = await showBusyMaxModalDialog<_WebCalAddInput>( + context, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + barrierDismissible: false, + builder: (dialogContext) => _WebCalAddDialog(initialUrl: initialUrl), + ); + if (input == null || !context.mounted) return; + try { + await ref + .read(webCalSubscriptionServiceProvider) + .addSubscription( + subscriptionUrl: input.url, + localName: input.name, + color: input.color, + refreshMode: input.refreshMode, + ); + } on Object catch (error) { + if (!context.mounted) return; + final code = switch (error) { + WebCalSubscriptionException(:final code) => code, + WebCalUriException(:final code) => code, + _ => 'WebCalOperationFailed', + }; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.subscriptionOperationFailed(code))), + ); + } +} + class _AccountManagementCard extends StatelessWidget { const _AccountManagementCard({ required this.account, required this.removing, required this.onReconnect, - required this.onCreateTaskList, required this.onRefreshCollections, required this.onRemoveAccount, }); @@ -1180,7 +1610,6 @@ class _AccountManagementCard extends StatelessWidget { final AccountEntity account; final bool removing; final VoidCallback? onReconnect; - final VoidCallback? onCreateTaskList; final VoidCallback? onRefreshCollections; final VoidCallback onRemoveAccount; @@ -1235,14 +1664,7 @@ class _AccountManagementCard extends StatelessWidget { color: Theme.of(context).colorScheme.error, ), onTap: account.needsReconnect ? onReconnect : null, - ) - else if (onCreateTaskList != null) ...[ - BusyMaxActionRow( - title: l10n.newTaskList, - leading: const Icon(YaruIcons.plus), - onTap: removing ? null : onCreateTaskList, ), - ], if (onRefreshCollections != null) BusyMaxActionRow( title: l10n.refreshCollections, @@ -1571,6 +1993,7 @@ String _accountProviderLabel(BuildContext context, BusyProvider provider) { BusyProvider.microsoft => context.l10n.microsoftProvider, BusyProvider.appleICloud => context.l10n.appleICloudProvider, BusyProvider.nextcloud => context.l10n.nextcloudProvider, + BusyProvider.webCal => 'WebCal', }; } diff --git a/lib/src/features/sync/account_sync_operations.dart b/lib/src/features/sync/account_sync_operations.dart index c644e028..ba36ff37 100644 --- a/lib/src/features/sync/account_sync_operations.dart +++ b/lib/src/features/sync/account_sync_operations.dart @@ -1,6 +1,10 @@ +import 'dart:async'; + +import '../../providers/busy_provider.dart'; + typedef AccountSyncAction = Future Function(String accountId, {required bool full}); -typedef AccountUsesDav = Future Function(String accountId); +typedef AccountProvider = Future Function(String accountId); abstract interface class AccountSyncOperations { Future syncAccount(String accountId, {required bool full}); @@ -10,6 +14,71 @@ abstract interface class AccountSyncOperations { Future syncCalendar(String accountId, {required bool full}); } +/// Serializes synchronization for each account while allowing different +/// accounts to synchronize independently. +/// +/// The returned future belongs to the submitted operation, so callers retain +/// their own success or failure result even when they were queued behind a +/// different synchronization trigger. +final class AccountSyncCoordinator { + final Map> _accountTails = {}; + + Future run(String accountId, Future Function() operation) async { + final previous = _accountTails[accountId]; + final release = Completer(); + final tail = release.future; + _accountTails[accountId] = tail; + + if (previous != null) { + await previous; + } + try { + return await operation(); + } finally { + release.complete(); + if (identical(_accountTails[accountId], tail)) { + _accountTails.remove(accountId); + } + } + } +} + +/// Routes every synchronization entry point through one account-scoped gate. +final class CoordinatedAccountSyncOperations implements AccountSyncOperations { + const CoordinatedAccountSyncOperations({ + required AccountSyncOperations inner, + required AccountSyncCoordinator coordinator, + }) : _inner = inner, + _coordinator = coordinator; + + final AccountSyncOperations _inner; + final AccountSyncCoordinator _coordinator; + + @override + Future syncAccount(String accountId, {required bool full}) { + return _coordinator.run( + accountId, + () => _inner.syncAccount(accountId, full: full), + ); + } + + @override + Future syncCalendar(String accountId, {required bool full}) { + return _coordinator.run( + accountId, + () => _inner.syncCalendar(accountId, full: full), + ); + } + + @override + Future syncTasks(String accountId, {required bool full}) { + return _coordinator.run( + accountId, + () => _inner.syncTasks(accountId, full: full), + ); + } +} + final class ConnectivityAwareAccountSyncOperations implements AccountSyncOperations { const ConnectivityAwareAccountSyncOperations({ @@ -82,45 +151,63 @@ final class DisabledAccountSyncOperations implements AccountSyncOperations { final class RoutingAccountSyncOperations implements AccountSyncOperations { const RoutingAccountSyncOperations({ - required AccountUsesDav usesDav, + required AccountProvider providerForAccount, required AccountSyncAction syncDav, + required AccountSyncAction syncWebCal, required AccountSyncAction syncTasksRest, required AccountSyncAction syncCalendarRest, - }) : _usesDav = usesDav, + }) : _providerForAccount = providerForAccount, _syncDav = syncDav, + _syncWebCal = syncWebCal, _syncTasksRest = syncTasksRest, _syncCalendarRest = syncCalendarRest; - final AccountUsesDav _usesDav; + final AccountProvider _providerForAccount; final AccountSyncAction _syncDav; + final AccountSyncAction _syncWebCal; final AccountSyncAction _syncTasksRest; final AccountSyncAction _syncCalendarRest; @override Future syncAccount(String accountId, {required bool full}) async { - if (await _usesDav(accountId)) { - await _syncDav(accountId, full: full); - return; + switch (await _providerForAccount(accountId)) { + case BusyProvider.appleICloud: + case BusyProvider.nextcloud: + await _syncDav(accountId, full: full); + case BusyProvider.webCal: + await _syncWebCal(accountId, full: full); + case BusyProvider.google: + case BusyProvider.microsoft: + await _syncTasksRest(accountId, full: full); + await _syncCalendarRest(accountId, full: full); } - await _syncTasksRest(accountId, full: full); - await _syncCalendarRest(accountId, full: full); } @override Future syncCalendar(String accountId, {required bool full}) async { - if (await _usesDav(accountId)) { - await _syncDav(accountId, full: full); - return; + switch (await _providerForAccount(accountId)) { + case BusyProvider.appleICloud: + case BusyProvider.nextcloud: + await _syncDav(accountId, full: full); + case BusyProvider.webCal: + await _syncWebCal(accountId, full: full); + case BusyProvider.google: + case BusyProvider.microsoft: + await _syncCalendarRest(accountId, full: full); } - await _syncCalendarRest(accountId, full: full); } @override Future syncTasks(String accountId, {required bool full}) async { - if (await _usesDav(accountId)) { - await _syncDav(accountId, full: full); - return; + switch (await _providerForAccount(accountId)) { + case BusyProvider.appleICloud: + case BusyProvider.nextcloud: + await _syncDav(accountId, full: full); + case BusyProvider.webCal: + return; + case BusyProvider.google: + case BusyProvider.microsoft: + await _syncTasksRest(accountId, full: full); } - await _syncTasksRest(accountId, full: full); } } diff --git a/lib/src/features/sync/all_accounts_sync_scheduler.dart b/lib/src/features/sync/all_accounts_sync_scheduler.dart index 271978cc..5082043f 100644 --- a/lib/src/features/sync/all_accounts_sync_scheduler.dart +++ b/lib/src/features/sync/all_accounts_sync_scheduler.dart @@ -4,31 +4,38 @@ import '../accounts/data/accounts_repository.dart'; class AllAccountsSyncScheduler { AllAccountsSyncScheduler({ - required Future> Function() listSignedInAccounts, + required Future> Function() listSyncEligibleAccounts, required Future Function(String accountId) syncAccount, required Future Function(Object error) onSyncFailure, Future Function(String accountId, Object error)? onAccountSyncFailure, Future Function()? canSync, Duration interval = const Duration(minutes: 15), - }) : _listSignedInAccounts = listSignedInAccounts, + }) : _listSyncEligibleAccounts = listSyncEligibleAccounts, _syncAccount = syncAccount, _onSyncFailure = onSyncFailure, _onAccountSyncFailure = onAccountSyncFailure, _canSync = canSync ?? _allowSync, _interval = interval; - final Future> Function() _listSignedInAccounts; + final Future> Function() _listSyncEligibleAccounts; final Future Function(String accountId) _syncAccount; final Future Function(Object error) _onSyncFailure; final Future Function(String accountId, Object error)? _onAccountSyncFailure; final Future Function() _canSync; final Duration _interval; + final _runningChanges = StreamController.broadcast(sync: true); Timer? _timer; bool _running = false; bool _runAgain = false; Duration get interval => _interval; + bool get isRunning => _running; + + Stream watchRunning() async* { + yield _running; + yield* _runningChanges.stream; + } void start() { stop(); @@ -46,20 +53,26 @@ class AllAccountsSyncScheduler { _runAgain = false; } + Future dispose() async { + stop(); + await _runningChanges.close(); + } + Future runNow() async { if (_running) { _runAgain = true; return; } _running = true; + if (!_runningChanges.isClosed) _runningChanges.add(true); try { do { _runAgain = false; if (!await _canSync()) { return; } - await runAllSignedInAccountSync( - listSignedInAccounts: _listSignedInAccounts, + await runAllSyncEligibleAccountSync( + listSyncEligibleAccounts: _listSyncEligibleAccounts, syncAccount: _syncAccount, onSyncFailure: _onSyncFailure, onAccountSyncFailure: _onAccountSyncFailure, @@ -67,19 +80,20 @@ class AllAccountsSyncScheduler { } while (_runAgain); } finally { _running = false; + if (!_runningChanges.isClosed) _runningChanges.add(false); } } } Future _allowSync() async => true; -Future runAllSignedInAccountSync({ - required Future> Function() listSignedInAccounts, +Future runAllSyncEligibleAccountSync({ + required Future> Function() listSyncEligibleAccounts, required Future Function(String accountId) syncAccount, required Future Function(Object error) onSyncFailure, Future Function(String accountId, Object error)? onAccountSyncFailure, }) async { - final accounts = await listSignedInAccounts(); + final accounts = await listSyncEligibleAccounts(); for (final account in accounts) { try { await syncAccount(account.id); diff --git a/lib/src/features/sync/calendar_pending_ops_replayer.dart b/lib/src/features/sync/calendar_pending_ops_replayer.dart index 32326feb..306e6348 100644 --- a/lib/src/features/sync/calendar_pending_ops_replayer.dart +++ b/lib/src/features/sync/calendar_pending_ops_replayer.dart @@ -3,15 +3,21 @@ import 'dart:math'; import 'package:drift/drift.dart'; -import '../../calendar_providers/calendar_mutation.dart'; +import '../../calendar_providers/calendar_create_identity.dart'; import '../../calendar_providers/calendar_description.dart'; +import '../../calendar_providers/calendar_mutation.dart'; import '../../calendar_providers/calendar_sync_dto.dart'; import '../../calendar_providers/cloud_calendar_client.dart'; +import '../../core/time/provider_date_time.dart'; import '../../db/app_database.dart'; import '../../google_calendar/google_calendar_errors.dart'; +import '../../google_calendar/google_calendar_mapper.dart'; import '../../microsoft_calendar/microsoft_calendar_errors.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import '../calendar/data/calendar_repository.dart'; +import '../recurrence/domain/event_recurrence_codec.dart'; +import '../recurrence/domain/recurrence_rule.dart'; +import 'pending_ops_replay_coordinator.dart'; class CalendarPendingOpsReplayer { CalendarPendingOpsReplayer({ @@ -37,7 +43,15 @@ class CalendarPendingOpsReplayer { final Random _random; final CalendarRepository _repository; - Future replayDueOps() async { + Future replayDueOps() { + return serializePendingOpsReplay( + database: _database, + accountId: _accountId, + replay: _replayDueOps, + ); + } + + Future _replayDueOps() async { final dueOps = await _database.pendingOpsDao.pendingOpsForReplay( _accountId, _nowUtc(), @@ -56,10 +70,18 @@ class CalendarPendingOpsReplayer { if (op.dependsOnOpId != null && await _opExists(op.dependsOnOpId!)) { continue; } + if (_copyConfirmationMissing(op)) { + await _blockOp( + op, + 'event_copy_not_confirmed', + 'The destination event was not confirmed, so the original was kept.', + ); + continue; + } try { await _replay(op); - await _database.pendingOpsDao.deleteOp(op.id); + await _acknowledge(op); applied += 1; } on GoogleCalendarApiError catch (error) { if (_isSuccessfulMissingDelete(op, error.statusCode)) { @@ -105,18 +127,16 @@ class CalendarPendingOpsReplayer { await _deleteEvent(op); case 'event.respond': await _respondToEvent(op); + case 'event.move': + await _moveEvent(op); case 'calendar.patch': await _patchCalendar(op); case 'calendar.delete': await _deleteCalendar(op); - case 'event.move': + case 'calendar.remove': + await _removeCalendar(op); case 'calendar.create': - await _blockOp( - op, - 'unsupported_calendar_operation', - _operationType(op), - ); - throw const _PendingOpBlocked(); + await _createCalendar(op); default: await _blockOp(op, 'unknown_calendar_operation', _operationType(op)); throw const _PendingOpBlocked(); @@ -157,48 +177,280 @@ class CalendarPendingOpsReplayer { Future _patchCalendar(PendingOp op) async { final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); - final source = await _client.updateCalendar( - providerCalendarId, + final request = _request(op); + final personal = + request[calendarMutationScopeKey] == calendarMutationScopePersonal || + (_client.provider == BusyProvider.google && + request[calendarMutationScopeKey] == null && + request['summary'] == null && + (request['backgroundColor'] != null || + request['foregroundColor'] != null || + request['colorId'] != null)); + await _requireCalendarPatchAllowed(op, personal: personal); + final mutation = _calendarMutation(request); + final source = personal + ? await _updateCalendarListEntry(op, providerCalendarId, mutation) + : await _client.updateCalendar(providerCalendarId, mutation); + await _repository.upsertSource(accountId: _accountId, source: source); + } + + Future _updateCalendarListEntry( + PendingOp op, + String providerCalendarId, + CalendarMutation mutation, + ) async { + final client = _client; + if (client is CalendarListManagementClient) { + return (client as CalendarListManagementClient).updateCalendarListEntry( + providerCalendarId, + mutation, + ); + } + await _blockOp( + op, + 'unsupported_calendar_operation', + 'The provider does not support personal calendar-list updates.', + ); + throw const _PendingOpBlocked(); + } + + Future _createCalendar(PendingOp op) async { + if (!_client.capabilities.supportsCreateCalendar) { + await _blockOp(op, 'unsupported_calendar_operation', _operationType(op)); + throw const _PendingOpBlocked(); + } + final source = await _client.createCalendar( _calendarMutation(_request(op)), ); - await _repository.upsertSource(accountId: _accountId, source: source); + await _replaceLocalCalendar(op, source); } Future _deleteCalendar(PendingOp op) async { final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); + await _requireCalendarRemovalAllowed(op, removeFromList: false); await _client.deleteCalendar(providerCalendarId); await _applyDeleteSideEffect(op); } + Future _removeCalendar(PendingOp op) async { + final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); + await _requireCalendarRemovalAllowed(op, removeFromList: true); + final client = _client; + if (client is! CalendarListManagementClient) { + await _blockOp( + op, + 'unsupported_calendar_operation', + 'The provider does not support removing calendar-list entries.', + ); + throw const _PendingOpBlocked(); + } + await (client as CalendarListManagementClient).deleteCalendarListEntry( + providerCalendarId, + ); + await _applyDeleteSideEffect(op); + } + + Future _requireCalendarPatchAllowed( + PendingOp op, { + required bool personal, + }) async { + final source = await _calendarSourceForOperation(op); + final provider = BusyProviderCodec.requireStorageValue(source.provider); + var allowed = false; + if (personal) { + allowed = provider == BusyProvider.google && !source.isDeleted; + } else if (provider == BusyProvider.google) { + allowed = + !source.isDeleted && + (source.primaryCalendar || + await _googleDataOwnerMatchesAccount(source)); + } else if (provider == BusyProvider.microsoft) { + allowed = !source.readOnly && !source.isDeleted; + } + if (allowed) return; + await _blockOp( + op, + 'calendar_permission_changed', + 'Calendar update permission is no longer available.', + ); + throw const _PendingOpBlocked(); + } + + Future _requireCalendarRemovalAllowed( + PendingOp op, { + required bool removeFromList, + }) async { + final source = await _calendarSourceForOperation(op); + final provider = BusyProviderCodec.requireStorageValue(source.provider); + final googleIdentity = provider == BusyProvider.google + ? await _googleAccountIdentity(source) + : null; + final googleOwner = source.dataOwner?.trim().toLowerCase(); + final ownsGoogleCalendar = + googleIdentity != null && + googleOwner != null && + googleOwner.isNotEmpty && + googleIdentity == googleOwner; + final allowed = removeFromList + ? provider == BusyProvider.google && + !source.primaryCalendar && + googleIdentity != null && + source.dataOwner?.trim().isNotEmpty == true && + !ownsGoogleCalendar + : !source.primaryCalendar && + ((provider == BusyProvider.google && ownsGoogleCalendar) || + (provider == BusyProvider.microsoft && + source.isRemovable == true)); + if (allowed) return; + await _blockOp( + op, + 'calendar_permission_changed', + removeFromList + ? 'This calendar can no longer be removed from the account.' + : 'This calendar can no longer be deleted.', + ); + throw const _PendingOpBlocked(); + } + + Future _calendarSourceForOperation(PendingOp op) async { + final sourceId = _require(op.calendarSourceId, 'calendarSourceId'); + final source = await (_database.select( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).getSingleOrNull(); + if (source != null) return source; + await _blockOp( + op, + 'calendar_not_found', + 'The local calendar record is unavailable.', + ); + throw const _PendingOpBlocked(); + } + + Future _googleDataOwnerMatchesAccount(CalendarSource source) async { + final owner = source.dataOwner?.trim().toLowerCase(); + if (owner == null || owner.isEmpty) return false; + final identity = await _googleAccountIdentity(source); + return identity != null && identity == owner; + } + + Future _googleAccountIdentity(CalendarSource source) async { + final account = await (_database.select( + _database.accounts, + )..where((row) => row.id.equals(source.accountId))).getSingleOrNull(); + final identity = account?.email?.trim().toLowerCase(); + return identity == null || identity.isEmpty ? null : identity; + } + Future _createEvent(PendingOp op) async { final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); + final request = await _eventCreateRequest(op); + 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), + ); + } on GoogleCalendarApiError catch (error) { + if (_client.provider != BusyProvider.google || + googleEventId == null || + error.statusCode != 409) { + rethrow; + } + event = await _client.getEvent( + calendarId: providerCalendarId, + eventId: googleEventId, + ); + } + await _replaceLocalEvent(op, event); + } + + Future> _eventCreateRequest(PendingOp op) async { final request = _request(op); - final event = await _client.createEvent( - calendarId: providerCalendarId, - mutation: _eventMutation( - request, - fallbackTimeZone: await _fallbackTimeZone(op), + final identity = switch (_client.provider) { + BusyProvider.google => ( + key: calendarEventGoogleCreateIdKey, + value: googleCalendarCreateEventId(op.id), ), - guestUpdatePolicy: _guestUpdatePolicy(request), - ); - await _replaceLocalEvent(op, event); + BusyProvider.microsoft => ( + key: calendarEventMicrosoftTransactionIdKey, + value: microsoftCalendarCreateTransactionId(op.id), + ), + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, + }; + if (identity == null || + request[identity.key]?.toString().trim().isNotEmpty == true) { + return request; + } + request[identity.key] = identity.value; + await (_database.update(_database.pendingOps) + ..where((row) => row.id.equals(op.id))) + .write(PendingOpsCompanion(requestJson: Value(jsonEncode(request)))); + return request; } Future _patchEvent(PendingOp op) async { final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); final local = await _localEvent(op); - final providerEventId = await _providerEventId(op, local); final request = _request(op); + final recurringScope = request[calendarEventRecurringScopeKey]?.toString(); + if (recurringScope == 'thisAndFuture') { + await _patchGoogleFollowingEvents(op, local: local, request: request); + return; + } + final providerEventId = await _providerEventId(op, local); await _ensureNoEventConflict(op, local, request); + var mutationRequest = request; + if (recurringScope == 'entireSeries') { + final resolved = request[_seriesResolvedRequestKey]; + if (resolved is Map) { + mutationRequest = resolved.cast(); + } else { + final master = await _client.getEvent( + calendarId: providerCalendarId, + eventId: providerEventId, + ); + mutationRequest = _seriesRequestForMaster( + request, + master, + provider: _client.provider, + ); + request[_seriesResolvedRequestKey] = mutationRequest; + await (_database.update( + _database.pendingOps, + )..where((row) => row.id.equals(op.id))).write( + PendingOpsCompanion( + requestJson: Value(jsonEncode(request)), + updatedAtUtc: Value(_nowUtc().toIso8601String()), + ), + ); + } + } final event = await _client.updateEvent( calendarId: providerCalendarId, eventId: providerEventId, mutation: _eventMutation( - request, + mutationRequest, fallbackTimeZone: await _fallbackTimeZone(op, local: local), ), guestUpdatePolicy: _guestUpdatePolicy(request), ); + if (recurringScope == 'entireSeries') { + await _repository.upsertEvent(accountId: _accountId, event: event); + await _markRecurringRowsSynced(local); + return; + } await _database.transaction(() async { final hasDependent = await _rebaseDependentEventEdits(op, event); if (hasDependent) { @@ -243,7 +495,7 @@ class CalendarPendingOpsReplayer { )..where((row) => row.id.equals(dependent.id))).write( PendingOpsCompanion( baselineRawJson: Value( - jsonEncode({_eventSemanticBaselineKey: baseline}), + jsonEncode({calendarEventSemanticBaselineKey: baseline}), ), updatedAtUtc: Value(_nowUtc().toIso8601String()), ), @@ -255,16 +507,53 @@ class CalendarPendingOpsReplayer { Future _deleteEvent(PendingOp op) async { final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); final local = await _localEvent(op); + final request = _request(op); + if (request[calendarEventRecurringScopeKey]?.toString() == + 'thisAndFuture') { + await _deleteGoogleFollowingEvents(op, local: local, request: request); + await _applyDeleteSideEffect(op); + return; + } final providerEventId = await _providerEventId(op, local); await _ensureEventUnchanged(op, local, 'delete'); await _client.deleteEvent( calendarId: providerCalendarId, eventId: providerEventId, - guestUpdatePolicy: _guestUpdatePolicy(_request(op)), + guestUpdatePolicy: _guestUpdatePolicy(request), ); await _applyDeleteSideEffect(op); } + Future _moveEvent(PendingOp op) async { + if (_client.provider != BusyProvider.google) { + await _blockOp( + op, + 'unsupported_calendar_operation', + 'Native event movement is only available for Google Calendar.', + ); + throw const _PendingOpBlocked(); + } + final sourceCalendarId = _require( + op.providerCalendarId, + 'sourceCalendarId', + ); + final request = _request(op); + final destinationCalendarId = _require( + request[calendarEventDestinationCalendarIdKey]?.toString(), + 'destinationCalendarId', + ); + final local = await _localEvent(op); + final providerEventId = await _providerEventId(op, local); + await _ensureEventUnchanged(op, local, 'move'); + final event = await _client.moveEvent( + sourceCalendarId: sourceCalendarId, + eventId: providerEventId, + destinationCalendarId: destinationCalendarId, + guestUpdatePolicy: _guestUpdatePolicy(request), + ); + await _replaceLocalEvent(op, event); + } + Future _respondToEvent(PendingOp op) async { final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); final local = await _localEvent(op); @@ -302,6 +591,295 @@ class CalendarPendingOpsReplayer { } } + Future _patchGoogleFollowingEvents( + PendingOp op, { + required CalendarEvent local, + required Map request, + }) async { + if (_client.provider != BusyProvider.google) { + await _blockOp( + op, + 'unsupported_recurring_scope', + 'This-and-following edits are not supported by this provider API.', + ); + throw const _PendingOpBlocked(); + } + final calendarId = _require(op.providerCalendarId, 'calendarId'); + final masterId = _require( + request[calendarEventTargetProviderIdKey]?.toString(), + 'recurringEventId', + ); + final currentMaster = await _client.getEvent( + calendarId: calendarId, + eventId: masterId, + ); + final snapshotValue = request[_googleSplitMasterRawKey]; + late final CalendarEventDto originalMaster; + if (snapshotValue is Map) { + originalMaster = googleCalendarEventFromJson( + calendarId, + snapshotValue.cast(), + ); + } else { + originalMaster = currentMaster; + request[_googleSplitMasterRawKey] = originalMaster.rawJson; + await (_database.update( + _database.pendingOps, + )..where((row) => row.id.equals(op.id))).write( + PendingOpsCompanion( + requestJson: Value(jsonEncode(request)), + updatedAtUtc: Value(_nowUtc().toIso8601String()), + ), + ); + } + final targetStart = _requiredDateTime( + request[calendarEventOriginalStartKey], + calendarEventOriginalStartKey, + ); + final masterStart = _dtoStart(originalMaster); + if (masterStart == null) { + throw StateError('The recurring master start is unavailable.'); + } + if (_sameOccurrenceStart( + masterStart, + targetStart, + allDay: originalMaster.allDay, + )) { + final mutationRequest = _seriesRequestForMaster( + request, + originalMaster, + provider: _client.provider, + ); + await _client.updateEvent( + calendarId: calendarId, + eventId: masterId, + mutation: _eventMutation( + mutationRequest, + fallbackTimeZone: await _fallbackTimeZone(op, local: local), + ), + guestUpdatePolicy: _guestUpdatePolicy(request), + ); + await _markRecurringRowsSynced(local); + return; + } + + final rule = EventRecurrenceCodec.decode( + BusyProvider.google, + originalMaster.recurrenceJson, + baseDate: masterStart, + ); + if (!rule.isSupported || !rule.repeats) { + await _blockOp( + op, + 'unsupported_recurrence_rule', + 'The Google recurrence rule cannot be split without data loss.', + ); + throw const _PendingOpBlocked(); + } + final trimmedRule = _trimRuleBefore( + rule, + targetStart, + allDay: originalMaster.allDay, + ); + final trimmedRecurrence = EventRecurrenceCodec.encode( + BusyProvider.google, + trimmedRule, + baseDate: masterStart, + allDay: originalMaster.allDay, + timeZone: originalMaster.startTimeZone, + original: originalMaster.recurrenceJson, + ); + final followingRule = await _followingGoogleRule( + rule, + calendarId: calendarId, + masterId: masterId, + masterStart: masterStart, + targetStart: targetStart, + ); + + await _client.updateEvent( + calendarId: calendarId, + eventId: masterId, + mutation: CalendarEventMutation(recurrence: trimmedRecurrence), + guestUpdatePolicy: _guestUpdatePolicy(request), + ); + + final splitRequest = { + ..._semanticSnapshot(BusyProvider.google, originalMaster.rawJson), + for (final entry in request.entries) + if (!_eventRequestMetadataFields.contains(entry.key)) + entry.key: entry.value, + 'allDay': request.containsKey('allDay') + ? request['allDay'] + : local.allDay, + 'start': request.containsKey('start') + ? request['start'] + : _localStart(local), + 'end': request.containsKey('end') ? request['end'] : _localEnd(local), + 'startTimeZone': request.containsKey('startTimeZone') + ? request['startTimeZone'] + : local.startTimeZone, + 'endTimeZone': request.containsKey('endTimeZone') + ? request['endTimeZone'] + : local.endTimeZone, + }; + final splitStart = _requiredDateTime(splitRequest['start'], 'start'); + splitRequest[calendarEventRecurrenceField] = EventRecurrenceCodec.encode( + BusyProvider.google, + followingRule, + baseDate: splitStart, + allDay: splitRequest['allDay'] == true, + timeZone: splitRequest['startTimeZone']?.toString(), + original: originalMaster.recurrenceJson, + ); + if (_newGoogleMeetRequest(originalMaster.rawJson, op.id) + case final conference?) { + splitRequest['conferenceJson'] = conference; + } else { + splitRequest.remove('conferenceJson'); + } + final providerRaw = {...originalMaster.rawJson}..remove('conferenceData'); + final splitEventId = op.id.replaceAll('-', '').toLowerCase(); + try { + await _client.createEvent( + calendarId: calendarId, + mutation: _eventMutation( + splitRequest, + fallbackTimeZone: await _fallbackTimeZone(op, local: local), + providerEventId: splitEventId, + providerRaw: providerRaw, + ), + guestUpdatePolicy: _guestUpdatePolicy(request), + ); + } on GoogleCalendarApiError catch (error) { + if (error.statusCode != 409) rethrow; + await _client.getEvent(calendarId: calendarId, eventId: splitEventId); + } + await _markRecurringRowsSynced(local); + } + + Future _deleteGoogleFollowingEvents( + PendingOp op, { + required CalendarEvent local, + required Map request, + }) async { + if (_client.provider != BusyProvider.google) { + await _blockOp( + op, + 'unsupported_recurring_scope', + 'This-and-following deletion is not supported by this provider API.', + ); + throw const _PendingOpBlocked(); + } + final calendarId = _require(op.providerCalendarId, 'calendarId'); + final masterId = _require( + request[calendarEventTargetProviderIdKey]?.toString(), + 'recurringEventId', + ); + final master = await _client.getEvent( + calendarId: calendarId, + eventId: masterId, + ); + final masterStart = _dtoStart(master); + if (masterStart == null) { + throw StateError('The recurring master start is unavailable.'); + } + final targetStart = _requiredDateTime( + request[calendarEventOriginalStartKey], + calendarEventOriginalStartKey, + ); + if (_sameOccurrenceStart(masterStart, targetStart, allDay: master.allDay)) { + await _client.deleteEvent( + calendarId: calendarId, + eventId: masterId, + guestUpdatePolicy: _guestUpdatePolicy(request), + ); + await _markRecurringRowsSynced(local); + return; + } + final rule = EventRecurrenceCodec.decode( + BusyProvider.google, + master.recurrenceJson, + baseDate: masterStart, + ); + if (!rule.isSupported || !rule.repeats) { + await _blockOp( + op, + 'unsupported_recurrence_rule', + 'The Google recurrence rule cannot be trimmed without data loss.', + ); + throw const _PendingOpBlocked(); + } + final trimmedRule = _trimRuleBefore( + rule, + targetStart, + allDay: master.allDay, + ); + await _client.updateEvent( + calendarId: calendarId, + eventId: masterId, + mutation: CalendarEventMutation( + recurrence: EventRecurrenceCodec.encode( + BusyProvider.google, + trimmedRule, + baseDate: masterStart, + allDay: master.allDay, + timeZone: master.startTimeZone, + original: master.recurrenceJson, + ), + ), + guestUpdatePolicy: _guestUpdatePolicy(request), + ); + await _markRecurringRowsSynced(local); + } + + Future _followingGoogleRule( + RecurrenceRule rule, { + required String calendarId, + required String masterId, + required DateTime masterStart, + required DateTime targetStart, + }) async { + final count = rule.count; + if (count == null) return rule; + final instances = await _client.listEventInstances( + calendarId: calendarId, + recurringEventId: masterId, + rangeStart: masterStart.subtract(const Duration(days: 1)), + rangeEnd: targetStart.add(const Duration(days: 1)), + ); + final before = instances.where((instance) { + final original = DateTime.tryParse( + instance.providerOriginalStartKey ?? '', + ); + return original != null && original.isBefore(targetStart); + }).length; + final remaining = count - before; + if (remaining < 1) { + throw StateError('The target occurrence is outside the recurrence.'); + } + return rule.copyWith(count: remaining, untilRaw: null); + } + + Future _markRecurringRowsSynced(CalendarEvent local) async { + final recurringEventId = local.providerRecurringEventId; + if (recurringEventId == null) return; + await (_database.update(_database.calendarEvents)..where( + (row) => + row.accountId.equals(local.accountId) & + row.provider.equals(local.provider) & + row.providerCalendarId.equals(local.providerCalendarId) & + row.providerRecurringEventId.equals(recurringEventId) & + row.syncStatus.equals('pending'), + )) + .write( + CalendarEventsCompanion( + syncStatus: const Value('synced'), + updatedAtLocal: Value(_nowUtc().millisecondsSinceEpoch), + ), + ); + } + Future _replaceLocalEvent( PendingOp op, CalendarEventDto serverEvent, @@ -318,6 +896,8 @@ class CalendarPendingOpsReplayer { await _database.transaction(() async { await _repository.upsertEvent(accountId: _accountId, event: serverEvent); + await _removeMovedSeriesSourceRows(op); + await _confirmDependentCopyDeletes(op, serverEventId); if (tempEventId != null && tempEventId != serverEventId) { await (_database.delete( _database.calendarEvents, @@ -339,11 +919,191 @@ class CalendarPendingOpsReplayer { }); } + Future _replaceLocalCalendar( + PendingOp op, + CalendarSourceDto serverSource, + ) async { + final temporarySourceId = op.calendarSourceId; + final temporaryProviderCalendarId = op.localTempId ?? op.providerCalendarId; + final localSource = temporarySourceId == null + ? null + : await (_database.select(_database.calendarSources) + ..where((row) => row.id.equals(temporarySourceId))) + .getSingleOrNull(); + final serverSourceId = CalendarRepository.sourceId( + accountId: _accountId, + provider: serverSource.provider, + providerCalendarId: serverSource.providerCalendarId, + ); + + await _database.transaction(() async { + await _repository.upsertSource( + accountId: _accountId, + source: serverSource, + ); + if (localSource != null) { + await (_database.update( + _database.calendarSources, + )..where((row) => row.id.equals(serverSourceId))).write( + CalendarSourcesCompanion( + selected: Value(localSource.selected), + remindersEnabled: Value(localSource.remindersEnabled), + hidden: Value(localSource.hidden), + backgroundColor: Value(localSource.backgroundColor), + foregroundColor: Value(localSource.foregroundColor), + colorId: Value(localSource.colorId), + createdAtLocal: Value(localSource.createdAtLocal), + updatedAtLocal: Value(_nowUtc().millisecondsSinceEpoch), + ), + ); + } + if (temporarySourceId != null && temporarySourceId != serverSourceId) { + await _database.customStatement( + 'UPDATE calendar_events SET calendar_source_id = ?, ' + 'provider_calendar_id = ? WHERE account_id = ? ' + 'AND calendar_source_id = ?', + [ + serverSourceId, + serverSource.providerCalendarId, + _accountId, + temporarySourceId, + ], + ); + await _database.customStatement( + 'UPDATE pending_ops SET calendar_source_id = ?, ' + 'provider_calendar_id = ? WHERE account_id = ? ' + 'AND calendar_source_id = ?', + [ + serverSourceId, + serverSource.providerCalendarId, + _accountId, + temporarySourceId, + ], + ); + } + if (temporaryProviderCalendarId != null && + temporaryProviderCalendarId != serverSource.providerCalendarId) { + await _database.customStatement( + 'UPDATE pending_ops SET provider_calendar_id = ? ' + 'WHERE account_id = ? AND provider_calendar_id = ?', + [ + serverSource.providerCalendarId, + _accountId, + temporaryProviderCalendarId, + ], + ); + await _database.customStatement( + 'UPDATE pending_ops SET local_temp_id = ? ' + 'WHERE account_id = ? AND local_temp_id = ?', + [ + serverSource.providerCalendarId, + _accountId, + temporaryProviderCalendarId, + ], + ); + } + await _rewriteCalendarPendingReferences( + oldSourceId: temporarySourceId, + newSourceId: serverSourceId, + oldProviderCalendarId: temporaryProviderCalendarId, + newProviderCalendarId: serverSource.providerCalendarId, + ); + if (temporarySourceId != null && temporarySourceId != serverSourceId) { + await (_database.delete( + _database.calendarSources, + )..where((row) => row.id.equals(temporarySourceId))).go(); + } + }); + } + + Future _rewriteCalendarPendingReferences({ + required String? oldSourceId, + required String newSourceId, + required String? oldProviderCalendarId, + required String newProviderCalendarId, + }) async { + final ops = await (_database.select( + _database.pendingOps, + )..where((row) => row.accountId.equals(_accountId))).get(); + for (final pending in ops) { + Object? rewritten = _request(pending); + if (oldSourceId != null && oldSourceId != newSourceId) { + rewritten = _replaceCalendarReference( + rewritten, + oldSourceId, + newSourceId, + ); + } + if (oldProviderCalendarId != null && + oldProviderCalendarId != newProviderCalendarId) { + rewritten = _replaceCalendarReference( + rewritten, + oldProviderCalendarId, + newProviderCalendarId, + ); + } + final requestJson = jsonEncode(rewritten); + if (requestJson == pending.requestJson) continue; + await (_database.update(_database.pendingOps) + ..where((row) => row.id.equals(pending.id))) + .write(PendingOpsCompanion(requestJson: Value(requestJson))); + } + } + + Future _removeMovedSeriesSourceRows(PendingOp op) async { + if (_operationType(op) != 'event.move') return; + final request = _request(op); + if (request[calendarEventRecurringScopeKey]?.toString() != 'entireSeries') { + return; + } + final recurringEventId = request[calendarEventTargetProviderIdKey] + ?.toString() + .trim(); + final sourceCalendarId = op.providerCalendarId; + if (recurringEventId == null || + recurringEventId.isEmpty || + sourceCalendarId == null) { + return; + } + await (_database.delete(_database.calendarEvents)..where( + (row) => + row.accountId.equals(_accountId) & + row.provider.equals( + op.provider ?? _client.provider.storageValue, + ) & + row.providerCalendarId.equals(sourceCalendarId) & + (row.providerEventId.equals(recurringEventId) | + row.providerRecurringEventId.equals(recurringEventId)), + )) + .go(); + } + + Future _confirmDependentCopyDeletes( + PendingOp completedCreate, + String destinationEventId, + ) async { + if (_operationType(completedCreate) != 'event.create') return; + final dependents = await (_database.select( + _database.pendingOps, + )..where((row) => row.dependsOnOpId.equals(completedCreate.id))).get(); + for (final dependent in dependents) { + final request = _request(dependent); + if (request[calendarEventCopyConfirmationRequiredKey] != true) continue; + request[calendarEventCopyConfirmedKey] = true; + request[calendarEventCopyDestinationEventIdKey] = destinationEventId; + await (_database.update(_database.pendingOps) + ..where((row) => row.id.equals(dependent.id))) + .write(PendingOpsCompanion(requestJson: Value(jsonEncode(request)))); + } + } + Future _applyDeleteSideEffect(PendingOp op) async { final eventId = op.eventId; if (eventId == null) { final sourceId = op.calendarSourceId; - if (_operationType(op) == 'calendar.delete' && sourceId != null) { + if ((_operationType(op) == 'calendar.delete' || + _operationType(op) == 'calendar.remove') && + sourceId != null) { await (_database.update( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).write( @@ -356,6 +1116,38 @@ class CalendarPendingOpsReplayer { } return; } + final request = _request(op); + final scope = request[calendarEventRecurringScopeKey]?.toString(); + if (scope == 'entireSeries' || scope == 'thisAndFuture') { + final local = await (_database.select( + _database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingleOrNull(); + final recurringEventId = local?.providerRecurringEventId; + if (local != null && recurringEventId != null) { + await (_database.update(_database.calendarEvents)..where((row) { + var predicate = + row.accountId.equals(local.accountId) & + row.provider.equals(local.provider) & + row.providerCalendarId.equals(local.providerCalendarId) & + (row.providerRecurringEventId.equals(recurringEventId) | + (scope == 'entireSeries' + ? row.providerEventId.equals(recurringEventId) + : const Constant(false))); + if (scope == 'thisAndFuture') { + predicate &= row.isDeleted.equals(true); + } + return predicate; + })) + .write( + CalendarEventsCompanion( + isDeleted: const Value(true), + syncStatus: const Value('synced'), + updatedAtLocal: Value(DateTime.now().millisecondsSinceEpoch), + ), + ); + return; + } + } await (_database.update( _database.calendarEvents, )..where((row) => row.id.equals(eventId))).write( @@ -377,7 +1169,7 @@ class CalendarPendingOpsReplayer { return; } final current = await _client.getEvent( - calendarId: local.providerCalendarId, + calendarId: op.providerCalendarId ?? local.providerCalendarId, eventId: await _providerEventId(op, local), ); final currentUpdatedUtc = _parseUtc(current.updatedAtServer); @@ -410,7 +1202,7 @@ class CalendarPendingOpsReplayer { return; } final current = await _client.getEvent( - calendarId: local.providerCalendarId, + calendarId: op.providerCalendarId ?? local.providerCalendarId, eventId: await _providerEventId(op, local), ); final currentUpdatedUtc = _parseUtc(current.updatedAtServer); @@ -439,10 +1231,24 @@ class CalendarPendingOpsReplayer { Set _eventMutationFields(Map request) { final clearFields = _eventClearFields(request); + const metadataFields = { + calendarEventClearFieldsKey, + calendarEventGuestUpdatePolicyKey, + calendarEventRecurringScopeKey, + calendarEventTargetProviderIdKey, + calendarEventOriginalStartKey, + calendarEventOriginalEndKey, + calendarEventDestinationCalendarIdKey, + calendarEventDestinationSourceIdKey, + calendarEventCopyConfirmationRequiredKey, + calendarEventCopyConfirmedKey, + calendarEventCopyDestinationEventIdKey, + _googleSplitMasterRawKey, + _seriesResolvedRequestKey, + }; return { for (final entry in request.entries) - if (entry.key != calendarEventClearFieldsKey && - entry.key != calendarEventGuestUpdatePolicyKey && + if (!metadataFields.contains(entry.key) && (entry.value != null || clearFields.contains(entry.key))) entry.key, }; @@ -453,7 +1259,7 @@ class CalendarPendingOpsReplayer { String encodedBaseline, ) { final raw = _jsonObject(encodedBaseline); - final semantic = raw[_eventSemanticBaselineKey]; + final semantic = raw[calendarEventSemanticBaselineKey]; if (semantic is Map) { return semantic.cast(); } @@ -527,17 +1333,22 @@ class CalendarPendingOpsReplayer { CalendarEventMutation _eventMutation( Map request, { String? fallbackTimeZone, + String? providerEventId, + String? transactionId, + Map? providerRaw, }) { final clearFields = _eventClearFields(request); final allDay = request['allDay'] == true; final start = request['start']?.toString(); final end = request['end']?.toString(); - final startTimeZone = allDay + final hasStartMutation = request.containsKey('start'); + final hasEndMutation = request.containsKey('end'); + final startTimeZone = allDay || !hasStartMutation ? null : _nonBlank(request['startTimeZone']?.toString()) ?? _nonBlank(fallbackTimeZone) ?? 'UTC'; - final endTimeZone = allDay + final endTimeZone = allDay || !hasEndMutation ? null : _nonBlank(request['endTimeZone']?.toString()) ?? startTimeZone ?? @@ -573,6 +1384,9 @@ class CalendarPendingOpsReplayer { responseRequested: request['responseRequested'] as bool?, hideAttendees: request['hideAttendees'] as bool?, allowNewTimeProposals: request['allowNewTimeProposals'] as bool?, + providerEventId: providerEventId, + transactionId: transactionId, + providerRaw: providerRaw, ); } @@ -648,6 +1462,10 @@ class CalendarPendingOpsReplayer { } Future _providerEventId(PendingOp op, CalendarEvent local) async { + final target = _request( + op, + )[calendarEventTargetProviderIdKey]?.toString().trim(); + if (target != null && target.isNotEmpty) return target; if (local.providerEventId.startsWith('local:')) { await _blockOp( op, @@ -681,7 +1499,8 @@ class CalendarPendingOpsReplayer { bool _isSuccessfulMissingDelete(PendingOp op, int statusCode) { return statusCode == 404 && (_operationType(op) == 'event.delete' || - _operationType(op) == 'calendar.delete'); + _operationType(op) == 'calendar.delete' || + _operationType(op) == 'calendar.remove'); } bool _isRetryableStatus(int statusCode) { @@ -692,9 +1511,19 @@ class CalendarPendingOpsReplayer { PendingOp op, String errorCode, String errorMessage, - ) { + ) async { final nextAttempt = _nextAttempt(op.attemptCount); - return _database.pendingOpsDao.updateAttempt( + if (_requiresRevisionMatch(op)) { + await _database.pendingOpsDao.updateAttemptIfUnchanged( + snapshot: op, + attemptCount: op.attemptCount + 1, + nextAttemptAtUtc: nextAttempt, + lastErrorCode: errorCode, + lastErrorMessage: errorMessage, + ); + return; + } + await _database.pendingOpsDao.updateAttempt( id: op.id, attemptCount: op.attemptCount + 1, nextAttemptAtUtc: nextAttempt, @@ -703,19 +1532,63 @@ class CalendarPendingOpsReplayer { ); } - Future _blockOp(PendingOp op, String errorCode, String errorMessage) { - return _database.pendingOpsDao.updateAttempt( + Future _blockOp( + PendingOp op, + String errorCode, + String errorMessage, + ) async { + final blockedUntil = DateTime.utc(9999, 12, 31); + final updated = _requiresRevisionMatch(op) + ? await _database.pendingOpsDao.updateAttemptIfUnchanged( + snapshot: op, + attemptCount: op.attemptCount + 1, + nextAttemptAtUtc: blockedUntil, + lastErrorCode: errorCode, + lastErrorMessage: errorMessage, + ) + : await _updateAttempt( + op, + nextAttemptAtUtc: blockedUntil, + errorCode: errorCode, + errorMessage: errorMessage, + ); + if (!updated) return false; + await _repository.restoreSourceAfterRemovalFailure(op); + return true; + } + + Future _updateAttempt( + PendingOp op, { + required DateTime nextAttemptAtUtc, + required String errorCode, + required String errorMessage, + }) async { + await _database.pendingOpsDao.updateAttempt( id: op.id, attemptCount: op.attemptCount + 1, - nextAttemptAtUtc: DateTime.utc(9999, 12, 31), + nextAttemptAtUtc: nextAttemptAtUtc, lastErrorCode: errorCode, lastErrorMessage: errorMessage, ); + return true; + } + + Future _acknowledge(PendingOp op) async { + if (_requiresRevisionMatch(op)) { + await _database.pendingOpsDao.deleteOpIfUnchanged(op); + return; + } + await _database.pendingOpsDao.deleteOp(op.id); + } + + bool _requiresRevisionMatch(PendingOp op) { + return _operationType(op) == 'calendar.patch'; } Future _blockConflict(PendingOp op, String message) async { - await _blockOp(op, 'conflict', message); - await _onConflictBlocked?.call(message); + if (await _blockOp(op, 'conflict', message)) { + await _onConflictBlocked?.call(message); + } throw const _PendingOpBlocked(); } @@ -731,6 +1604,12 @@ class CalendarPendingOpsReplayer { return _readOp(id).then((op) => op != null); } + bool _copyConfirmationMissing(PendingOp op) { + final request = _request(op); + return request[calendarEventCopyConfirmationRequiredKey] == true && + request[calendarEventCopyConfirmedKey] != true; + } + Future _readOp(String id) async { return (_database.select( _database.pendingOps, @@ -760,11 +1639,253 @@ class CalendarPendingOpsReplayer { } } +Object? _replaceCalendarReference( + Object? value, + String oldValue, + String newValue, +) { + if (value is String) { + return value == oldValue ? newValue : value; + } + if (value is List) { + return [ + for (final item in value) + _replaceCalendarReference(item, oldValue, newValue), + ]; + } + if (value is Map) { + return { + for (final entry in value.entries) + entry.key.toString(): _replaceCalendarReference( + entry.value, + oldValue, + newValue, + ), + }; + } + return value; +} + class _PendingOpBlocked { const _PendingOpBlocked(); } -const _eventSemanticBaselineKey = '__busymaxSemanticBaseline'; +const _googleSplitMasterRawKey = '_googleSplitMasterRaw'; +const _seriesResolvedRequestKey = '_seriesResolvedRequest'; + +const _eventRequestMetadataFields = { + calendarEventClearFieldsKey, + calendarEventGuestUpdatePolicyKey, + calendarEventRecurringScopeKey, + calendarEventTargetProviderIdKey, + calendarEventOriginalStartKey, + calendarEventOriginalEndKey, + _googleSplitMasterRawKey, + _seriesResolvedRequestKey, +}; + +Map _seriesRequestForMaster( + Map request, + CalendarEventDto master, { + required BusyProvider provider, +}) { + final result = {...request}; + if (request.containsKey('start')) { + final timeZone = request['startTimeZone']?.toString(); + final masterStart = providerDateTimeAsWallTime( + master.allDay ? master.startDate : master.startDateTime, + master.startTimeZone, + ); + final originalStart = providerDateTimeAsWallTime( + request[calendarEventOriginalStartKey]?.toString(), + timeZone, + ); + final desiredStart = DateTime.tryParse(request['start']?.toString() ?? ''); + if (masterStart == null || originalStart == null || desiredStart == null) { + throw StateError('The recurring series start could not be adjusted.'); + } + result['start'] = _naiveWallTime(masterStart) + .add( + _naiveWallTime( + desiredStart, + ).difference(_naiveWallTime(originalStart)), + ) + .toIso8601String(); + } + if (request.containsKey('end')) { + final timeZone = request['endTimeZone']?.toString(); + final masterEnd = providerDateTimeAsWallTime( + master.allDay ? master.endDate : master.endDateTime, + master.endTimeZone, + ); + final originalEnd = providerDateTimeAsWallTime( + request[calendarEventOriginalEndKey]?.toString(), + timeZone, + ); + final desiredEnd = DateTime.tryParse(request['end']?.toString() ?? ''); + if (masterEnd == null || originalEnd == null || desiredEnd == null) { + throw StateError('The recurring series end could not be adjusted.'); + } + result['end'] = _naiveWallTime(masterEnd) + .add(_naiveWallTime(desiredEnd).difference(_naiveWallTime(originalEnd))) + .toIso8601String(); + } + if (request.containsKey('start') && master.recurrenceJson != null) { + final adjustedStart = DateTime.tryParse(result['start']?.toString() ?? ''); + if (adjustedStart == null) { + throw StateError('The recurring series start could not be adjusted.'); + } + result[calendarEventRecurrenceField] = _reanchorSeriesRecurrence( + provider, + master.recurrenceJson!, + originalStart: providerDateTimeAsWallTime( + master.allDay ? master.startDate : master.startDateTime, + master.startTimeZone, + ), + adjustedStart: adjustedStart, + allDay: request['allDay'] as bool? ?? master.allDay, + originalTimeZone: master.startTimeZone, + adjustedTimeZone: + request['startTimeZone']?.toString() ?? master.startTimeZone, + ); + } + return result; +} + +Object _reanchorSeriesRecurrence( + BusyProvider provider, + Object original, { + required DateTime? originalStart, + required DateTime adjustedStart, + required bool allDay, + required String? originalTimeZone, + required String? adjustedTimeZone, +}) { + final rule = EventRecurrenceCodec.decode( + provider, + original, + baseDate: originalStart, + ); + if (rule.isSupported && rule.repeats) { + final untilDate = rule.untilDateFor(timeZone: originalTimeZone); + final adjustedRule = untilDate == null + ? rule + : rule.withUntilDate( + untilDate, + allDay: allDay, + baseDate: adjustedStart, + timeZone: adjustedTimeZone, + ); + return EventRecurrenceCodec.encode( + provider, + adjustedRule, + baseDate: adjustedStart, + allDay: allDay, + timeZone: adjustedTimeZone, + original: original, + )!; + } + if (provider == BusyProvider.microsoft && original is Map) { + final recurrence = Map.from(original); + final originalRange = recurrence['range']; + if (originalRange is Map) { + recurrence['range'] = { + ...Map.from(originalRange), + 'startDate': _isoDate(adjustedStart), + }; + } + return recurrence; + } + return original; +} + +String _isoDate(DateTime value) => + '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; + +DateTime _naiveWallTime(DateTime value) => DateTime( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.millisecond, + value.microsecond, +); + +DateTime _requiredDateTime(Object? value, String field) { + final parsed = DateTime.tryParse(value?.toString() ?? ''); + if (parsed == null) throw StateError('Missing or invalid $field.'); + return parsed; +} + +DateTime? _dtoStart(CalendarEventDto event) => DateTime.tryParse( + event.allDay ? event.startDate ?? '' : event.startDateTime ?? '', +); + +bool _sameOccurrenceStart( + DateTime left, + DateTime right, { + required bool allDay, +}) { + if (!allDay) return left.toUtc() == right.toUtc(); + return left.year == right.year && + left.month == right.month && + left.day == right.day; +} + +RecurrenceRule _trimRuleBefore( + RecurrenceRule rule, + DateTime targetStart, { + required bool allDay, +}) { + if (allDay) { + final previousDate = DateTime( + targetStart.year, + targetStart.month, + targetStart.day - 1, + ); + return rule.copyWith(count: null, untilRaw: _basicIcalDate(previousDate)); + } + final until = targetStart.toUtc().subtract(const Duration(seconds: 1)); + return rule.copyWith(count: null, untilRaw: _utcIcalDateTime(until)); +} + +String _basicIcalDate(DateTime value) => + '${value.year.toString().padLeft(4, '0')}' + '${value.month.toString().padLeft(2, '0')}' + '${value.day.toString().padLeft(2, '0')}'; + +String _utcIcalDateTime(DateTime value) { + final utc = value.toUtc(); + return '${_basicIcalDate(utc)}T' + '${utc.hour.toString().padLeft(2, '0')}' + '${utc.minute.toString().padLeft(2, '0')}' + '${utc.second.toString().padLeft(2, '0')}Z'; +} + +Object? _newGoogleMeetRequest(Map raw, String requestId) { + final conference = raw['conferenceData']; + if (conference is! Map) return null; + final solution = conference['conferenceSolution']; + final key = solution is Map ? solution['key'] : null; + final type = key is Map ? key['type']?.toString() : null; + if (type != 'hangoutsMeet') return null; + return { + 'createRequest': { + 'requestId': requestId, + 'conferenceSolutionKey': {'type': 'hangoutsMeet'}, + }, + }; +} + +String? _localStart(CalendarEvent event) => + event.allDay ? event.startDate : event.startDateTime; + +String? _localEnd(CalendarEvent event) => + event.allDay ? event.endDate : event.endDateTime; Map _mapValue(Object? value) { if (value is Map) { diff --git a/lib/src/features/sync/calendar_sync_engine.dart b/lib/src/features/sync/calendar_sync_engine.dart index 9cf3f3f1..6d234125 100644 --- a/lib/src/features/sync/calendar_sync_engine.dart +++ b/lib/src/features/sync/calendar_sync_engine.dart @@ -1,4 +1,5 @@ import '../../calendar_providers/cloud_calendar_client.dart'; +import '../../calendar_providers/calendar_sync_dto.dart'; import '../../db/app_database.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import '../calendar/data/calendar_repository.dart'; @@ -37,10 +38,7 @@ class CalendarSyncEngine { Future fullSync() async { await _replayPendingOps(); - final calendars = await _client.listCalendars(); - for (final calendar in calendars) { - await _repository.upsertSource(accountId: _accountId, source: calendar); - } + final calendars = await _refreshCalendarSources(); final window = _calendarSyncWindow(_nowUtc()); for (final calendar in calendars.where((source) => !source.isDeleted)) { @@ -61,10 +59,7 @@ class CalendarSyncEngine { Future incrementalSync() async { await _replayPendingOps(); - final calendars = await _client.listCalendars(); - for (final calendar in calendars) { - await _repository.upsertSource(accountId: _accountId, source: calendar); - } + final calendars = await _refreshCalendarSources(); final window = _calendarSyncWindow(_nowUtc()); final rangeStartValue = window.start.toIso8601String(); @@ -121,6 +116,26 @@ class CalendarSyncEngine { await _onNotificationScheduleChanged?.call(); } + Future> _refreshCalendarSources() async { + // listCalendars() returns only after every page has been retrieved, so an + // absent Microsoft calendar can be treated as a provider-side deletion. + final calendars = await _client.listCalendars(); + for (final calendar in calendars) { + await _repository.upsertSource(accountId: _accountId, source: calendar); + } + if (provider == BusyProvider.microsoft) { + await _repository.reconcileProviderSourceSnapshot( + accountId: _accountId, + provider: provider, + activeProviderCalendarIds: { + for (final calendar in calendars) + if (!calendar.isDeleted) calendar.providerCalendarId, + }, + ); + } + return calendars; + } + Future _syncCalendarRange({ required String providerCalendarId, required bool primaryCalendar, diff --git a/lib/src/features/sync/pending_op_resolution_service.dart b/lib/src/features/sync/pending_op_resolution_service.dart index 8eaad26b..baa86b0e 100644 --- a/lib/src/features/sync/pending_op_resolution_service.dart +++ b/lib/src/features/sync/pending_op_resolution_service.dart @@ -1,34 +1,43 @@ import 'dart:convert'; +import '../../calendar_providers/cloud_calendar_client.dart'; import '../../db/app_database.dart'; +import '../calendar/data/calendar_repository.dart'; import '../task_lists/data/task_lists_repository.dart'; import '../tasks/data/tasks_repository.dart'; import '../tasks/domain/task_remote_client.dart'; import '../tasks/domain/task_remote_error.dart'; -import 'sync_engine.dart'; class PendingOpResolutionService { PendingOpResolutionService({ required AppDatabase database, - required TaskRemoteClient apiClient, + TaskRemoteClient? apiClient, + CloudCalendarClient? calendarClient, required String accountId, - required SyncEngine syncEngine, + required Future Function() syncTasks, + required Future Function() syncCalendar, DateTime Function()? nowUtc, }) : _database = database, _apiClient = apiClient, + _calendarClient = calendarClient, _accountId = accountId, - _syncEngine = syncEngine, + _syncTasks = syncTasks, + _syncCalendar = syncCalendar, _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()); final AppDatabase _database; - final TaskRemoteClient _apiClient; + final TaskRemoteClient? _apiClient; + final CloudCalendarClient? _calendarClient; final String _accountId; - final SyncEngine _syncEngine; + final Future Function() _syncTasks; + final Future Function() _syncCalendar; final DateTime Function() _nowUtc; Future retryNow(String opId) async { + final op = await _database.pendingOpsDao.getOp(opId); + if (op == null) return; await _database.pendingOpsDao.retryNow(opId, _nowUtc()); - await _syncEngine.incrementalSync(); + await _syncAfterResolution(op); } Future discard(String opId) async { @@ -37,24 +46,73 @@ class PendingOpResolutionService { return; } - await _refreshOrRemoveLocalState(op); + final syncAfterDiscard = await _refreshOrRemoveLocalState(op); await _database.pendingOpsDao.deleteOp(op.id); - await _syncEngine.incrementalSync(); + if (syncAfterDiscard) { + await _syncAfterResolution(op); + } } - Future _refreshOrRemoveLocalState(PendingOp op) async { + Future _refreshOrRemoveLocalState(PendingOp op) async { + if (op.entityType == 'calendar') { + final repository = CalendarRepository(database: _database, now: _nowUtc); + switch (_operationType(op)) { + case 'calendar.create': + await repository.discardPendingCalendarCreation(op); + return false; + case 'calendar.delete' || 'calendar.remove': + await repository.restoreSourceAfterRemovalFailure(op); + return true; + case 'calendar.patch': + await repository.restoreSourceAfterPatchDiscard(op); + return true; + } + return false; + } + + if (op.entityType == 'event') { + final repository = CalendarRepository(database: _database, now: _nowUtc); + if (_operationType(op) == 'event.create') { + await repository.discardPendingEventCreation(op); + return false; + } + await repository.restoreEventAfterMutationDiscard( + op, + fetchProviderEvent: _calendarClient == null + ? null + : ({required calendarId, required eventId}) => _calendarClient + .getEvent(calendarId: calendarId, eventId: eventId), + ); + return true; + } + if (op.entityType == 'task' && op.taskListId != null && op.taskId != null) { if (op.operation == 'move_task') { await _refreshOrRemoveMovedTask(op); - return; + return true; } await _refreshOrRemoveTask(op.taskListId!, op.taskId!); - return; + return true; } if (op.entityType == 'task_list' && op.taskListId != null) { await _refreshOrRemoveTaskList(op.taskListId!); + return true; + } + return false; + } + + Future _syncAfterResolution(PendingOp op) async { + if (op.entityType == 'calendar' || op.entityType == 'event') { + await _syncCalendar(); + return; } + await _syncTasks(); + } + + TaskRemoteClient get _requiredTaskClient { + return _apiClient ?? + (throw StateError('Task operation recovery is unavailable.')); } Future _refreshOrRemoveMovedTask(PendingOp op) async { @@ -63,7 +121,7 @@ class PendingOpResolutionService { final destinationTaskListId = _destinationTaskListId(op); try { - final dto = await _apiClient.getTask( + final dto = await _requiredTaskClient.getTask( taskListId: sourceTaskListId, taskId: taskId, ); @@ -96,7 +154,7 @@ class PendingOpResolutionService { Future _refreshOrRemoveTask(String taskListId, String taskId) async { try { - final dto = await _apiClient.getTask( + final dto = await _requiredTaskClient.getTask( taskListId: taskListId, taskId: taskId, ); @@ -113,7 +171,7 @@ class PendingOpResolutionService { Future _refreshOrRemoveTaskList(String taskListId) async { try { - final dto = await _apiClient.getTaskList(taskListId); + final dto = await _requiredTaskClient.getTaskList(taskListId); await _database.taskListsDao.upsertTaskList( taskListFromDto(_accountId, dto, _now()), ); @@ -130,5 +188,9 @@ class PendingOpResolutionService { return request['destinationTasklist']?.toString(); } + String _operationType(PendingOp op) { + return op.operationType ?? '${op.entityType}.${op.operation}'; + } + String _now() => _nowUtc().toIso8601String(); } diff --git a/lib/src/features/sync/pending_ops_replay_coordinator.dart b/lib/src/features/sync/pending_ops_replay_coordinator.dart new file mode 100644 index 00000000..8f180984 --- /dev/null +++ b/lib/src/features/sync/pending_ops_replay_coordinator.dart @@ -0,0 +1,16 @@ +import '../../db/app_database.dart'; +import 'account_sync_operations.dart'; + +final Expando _coordinators = + Expando('pending-operation replay coordinators'); + +/// Prevents independently-created replayers from dispatching pending writes +/// concurrently for the same database and account. +Future serializePendingOpsReplay({ + required AppDatabase database, + required String accountId, + required Future Function() replay, +}) { + final coordinator = _coordinators[database] ??= AccountSyncCoordinator(); + return coordinator.run(accountId, replay); +} diff --git a/lib/src/features/sync/pending_ops_replayer.dart b/lib/src/features/sync/pending_ops_replayer.dart index 2887c549..d6600127 100644 --- a/lib/src/features/sync/pending_ops_replayer.dart +++ b/lib/src/features/sync/pending_ops_replayer.dart @@ -8,6 +8,7 @@ import '../tasks/domain/task_remote_client.dart'; import '../tasks/domain/task_remote_error.dart'; import '../tasks/domain/task_remote_models.dart'; import 'conflict_detector.dart'; +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'; @@ -34,44 +35,68 @@ class PendingOpsReplayer { final Random _random; final DateTime Function() _nowUtc; - Future replayDueOps() async { + Future replayDueOps() { + return serializePendingOpsReplay( + database: _database, + accountId: _accountId, + replay: _replayDueOps, + ); + } + + Future _replayDueOps() async { final ops = await _database.pendingOpsDao.pendingOpsForReplay( _accountId, _nowUtc(), ); var applied = 0; + final handledIds = {}; + var madeProgress = true; + + while (madeProgress) { + madeProgress = false; + for (final originalOp in ops) { + if (handledIds.contains(originalOp.id)) continue; + final op = await _readOp(originalOp.id); + if (op == null || !_isTaskOp(op)) { + handledIds.add(originalOp.id); + continue; + } + if (op.dependsOnOpId != null && await _opExists(op.dependsOnOpId!)) { + continue; + } + if (op.operation == 'create_task' && !await _claimTaskCreate(op)) { + continue; + } + handledIds.add(op.id); + madeProgress = true; - for (final originalOp in ops) { - final op = await _readOp(originalOp.id); - if (op == null || !_isTaskOp(op)) { - continue; - } - if (op.dependsOnOpId != null && await _opExists(op.dependsOnOpId!)) { - continue; - } - - try { - await _replay(op); - await _database.pendingOpsDao.deleteOp(op.id); - applied += 1; - } on TaskRemoteError catch (error) { - if (_isSuccessfulMissingDelete(op, error)) { - await _applyDeleteSideEffect(op); + try { + await _replay(op); await _database.pendingOpsDao.deleteOp(op.id); applied += 1; - } else if (_isRetryableStatus(error.statusCode)) { - await _scheduleRetry(op, error.statusCode.toString(), error.message); - } else { - await _blockOp(op, error.statusCode.toString(), error.message); + } on TaskRemoteError catch (error) { + if (_isSuccessfulMissingDelete(op, error)) { + await _applyDeleteSideEffect(op); + await _database.pendingOpsDao.deleteOp(op.id); + applied += 1; + } else if (_isRetryableStatus(error.statusCode)) { + await _scheduleRetry( + op, + error.statusCode.toString(), + error.message, + ); + } else { + await _blockOp(op, error.statusCode.toString(), error.message); + } + } on _PendingOpBlocked { + continue; + } on Object catch (error) { + await _scheduleRetry( + op, + error.runtimeType.toString(), + error.toString(), + ); } - } on _PendingOpBlocked { - continue; - } on Object catch (error) { - await _scheduleRetry( - op, - error.runtimeType.toString(), - error.toString(), - ); } } @@ -140,7 +165,11 @@ class PendingOpsReplayer { 'AND task_list_id = ?', [dto.id, _accountId, tempId], ); - await _replacePendingReference(tempId, dto.id); + await _replacePendingReference( + tempId, + dto.id, + completedCreateOpId: op.id, + ); await _database.taskListsDao.deleteTaskList(_accountId, tempId); }); } @@ -195,6 +224,7 @@ class PendingOpsReplayer { taskListId: op.taskListId!, tempTaskId: tempId, serverTask: dto, + completedCreateOpId: op.id, ); } @@ -248,8 +278,11 @@ class PendingOpsReplayer { final acknowledgedFields = _request(completedOp).keys; for (final dependent in dependents) { final baseline = _jsonObject(dependent.baselineRawJson ?? '{}'); + final usesWholeTaskConflictBoundary = + dependent.operation == 'move_task' || + dependent.operation == 'delete_task'; // Keep the original timestamp and untouched fields so a provider edit to - // a different field is still detected by the dependent operation. + // a different field is still detected by a dependent field-level edit. for (final field in acknowledgedFields) { baseline[field] = serverTask.rawJson[field]; } @@ -258,6 +291,10 @@ class PendingOpsReplayer { )..where((row) => row.id.equals(dependent.id))).write( PendingOpsCompanion( baselineRawJson: Value(jsonEncode(baseline)), + // Whole-task checks must advance past the acknowledged local write. + baselineUpdatedUtc: usesWholeTaskConflictBoundary + ? Value(serverTask.updated?.toUtc().toIso8601String()) + : const Value.absent(), updatedAtUtc: Value(_now()), ), ); @@ -495,13 +532,13 @@ class PendingOpsReplayer { required String taskListId, required String tempTaskId, required TaskDto serverTask, + required String completedCreateOpId, }) async { await _database.transaction(() async { final localTask = await (_database.select(_database.tasks)..where( (row) => row.accountId.equals(_accountId) & - row.taskListId.equals(taskListId) & row.id.equals(tempTaskId), )) .getSingleOrNull(); @@ -528,15 +565,27 @@ class PendingOpsReplayer { 'UPDATE tasks SET parent = ? WHERE account_id = ? AND parent = ?', [serverTask.id, _accountId, tempTaskId], ); - await _replacePendingReference(tempTaskId, serverTask.id); - await _database.tasksDao.deleteTask(_accountId, taskListId, tempTaskId); + await _replacePendingReference( + tempTaskId, + serverTask.id, + completedCreateOpId: completedCreateOpId, + ); + await _database.tasksDao.deleteTask( + _accountId, + localTask?.taskListId ?? taskListId, + tempTaskId, + ); }); } Future _replacePendingReference( String oldValue, - String newValue, - ) async { + String newValue, { + required String completedCreateOpId, + }) async { + final ops = await (_database.select( + _database.pendingOps, + )..where((row) => row.accountId.equals(_accountId))).get(); await _database.customStatement( 'UPDATE pending_ops SET task_list_id = ? WHERE account_id = ? ' 'AND task_list_id = ?', @@ -552,21 +601,33 @@ class PendingOpsReplayer { [newValue, _accountId, oldValue], ); - final ops = await (_database.select( - _database.pendingOps, - )..where((row) => row.accountId.equals(_accountId))).get(); for (final op in ops) { final request = _request(op); final rewritten = _replaceJsonReference(request, oldValue, newValue); final rewrittenJson = jsonEncode(rewritten); - if (rewrittenJson == op.requestJson) { + final referenceChanged = + op.taskListId == oldValue || + op.taskId == oldValue || + op.localTempId == oldValue || + rewrittenJson != op.requestJson; + final unblock = + op.id != completedCreateOpId && + referenceChanged && + op.nextAttemptAtUtc?.startsWith('9999-12-31') == true; + if (rewrittenJson == op.requestJson && !unblock) { continue; } await (_database.update( _database.pendingOps, )..where((row) => row.id.equals(op.id))).write( PendingOpsCompanion( - requestJson: Value(rewrittenJson), + requestJson: rewrittenJson == op.requestJson + ? const Value.absent() + : Value(rewrittenJson), + state: unblock ? const Value('pending') : const Value.absent(), + nextAttemptAtUtc: unblock ? const Value(null) : const Value.absent(), + lastErrorCode: unblock ? const Value(null) : const Value.absent(), + lastErrorMessage: unblock ? const Value(null) : const Value.absent(), updatedAtUtc: Value(_now()), ), ); @@ -611,25 +672,57 @@ class PendingOpsReplayer { PendingOp op, String errorCode, String errorMessage, - ) { + ) async { final nextAttempt = _nextAttempt(op.attemptCount); - return _database.pendingOpsDao.updateAttempt( + await _database.pendingOpsDao.updateAttempt( id: op.id, attemptCount: op.attemptCount + 1, nextAttemptAtUtc: nextAttempt, lastErrorCode: errorCode, lastErrorMessage: errorMessage, ); + if (op.operation == 'create_task') { + await _setPendingOpState(op.id, 'retry'); + } } - Future _blockOp(PendingOp op, String errorCode, String errorMessage) { - return _database.pendingOpsDao.updateAttempt( + Future _blockOp( + PendingOp op, + String errorCode, + String errorMessage, + ) async { + await _database.pendingOpsDao.updateAttempt( id: op.id, attemptCount: op.attemptCount + 1, nextAttemptAtUtc: DateTime.utc(9999, 12, 31), lastErrorCode: errorCode, lastErrorMessage: errorMessage, ); + if (op.operation == 'create_task') { + await _setPendingOpState(op.id, 'failed'); + } + } + + Future _claimTaskCreate(PendingOp op) async { + final query = _database.update(_database.pendingOps) + ..where( + (row) => + row.id.equals(op.id) & + row.state.equals(op.state) & + row.attemptCount.equals(op.attemptCount) & + row.requestJson.equals(op.requestJson) & + row.updatedAtUtc.equals(op.updatedAtUtc), + ); + return await query.write( + const PendingOpsCompanion(state: Value('in_progress')), + ) == + 1; + } + + Future _setPendingOpState(String id, String state) { + return (_database.update(_database.pendingOps) + ..where((row) => row.id.equals(id))) + .write(PendingOpsCompanion(state: Value(state))); } DateTime _nextAttempt(int attemptCount) { diff --git a/lib/src/features/sync/sync_engine.dart b/lib/src/features/sync/sync_engine.dart index 386c3404..97fe0a3e 100644 --- a/lib/src/features/sync/sync_engine.dart +++ b/lib/src/features/sync/sync_engine.dart @@ -36,36 +36,42 @@ class SyncEngine { final DateTime Function() _nowUtc; Future fullSync() { - return _runSync(mode: 'full', updatedMin: null, markMissing: true); + return _runSync(mode: 'full', updatedMin: null, markMissingTasks: true); } Future incrementalSync() async { if (_fullRefreshOnly) { // Microsoft To Do sync uses full refresh in this version. Graph delta // endpoints exist in the low-level client but are not used by app sync. - await _runSync(mode: 'incremental', updatedMin: null, markMissing: true); + await _runSync( + mode: 'incremental', + updatedMin: null, + markMissingTasks: true, + ); return; } - final account = await (_database.select( - _database.accounts, - )..where((row) => row.id.equals(_accountId))).getSingleOrNull(); - final lastSync = account?.lastSuccessfulSyncAtUtc == null + final lastSuccessfulRun = await _database.syncRunsDao.latestSuccessfulRun( + _accountId, + ); + // A completion-time checkpoint can skip changes made to an early list + // while later lists are still being synchronized. + final checkpoint = lastSuccessfulRun == null ? null - : DateTime.parse(account!.lastSuccessfulSyncAtUtc!).toUtc(); - final updatedMin = lastSync?.subtract(const Duration(minutes: 2)); + : DateTime.parse(lastSuccessfulRun.startedAtUtc).toUtc(); + final updatedMin = checkpoint?.subtract(const Duration(minutes: 2)); await _runSync( mode: 'incremental', updatedMin: updatedMin, - markMissing: false, + markMissingTasks: false, ); } Future _runSync({ required String mode, required DateTime? updatedMin, - required bool markMissing, + required bool markMissingTasks, }) async { final runId = _uuid.v4(); final startedAt = _now(); @@ -91,6 +97,7 @@ class SyncEngine { onConflictBlocked: _onConflictBlocked, ).replayDueOps(); final listIds = await _pullTaskLists(); + await _reconcileTaskListMembership(listIds); taskListsSeen = listIds.length; final tasksByList = >{}; for (final taskListId in listIds) { @@ -99,8 +106,8 @@ class SyncEngine { tasksSeen += taskIds.length; } - if (markMissing) { - await _markMissingRows(listIds, tasksByList); + if (markMissingTasks) { + await _markMissingTasks(tasksByList); } await NotificationScheduleService( database: _database, @@ -108,7 +115,7 @@ class SyncEngine { ).rebuildUpcomingTaskNotifications(_accountId); final finishedAt = _now(); - await _updateAccountSyncTimestamps(mode, finishedAt); + await _updateAccountSyncCompletion(mode, finishedAt); await _database.syncRunsDao.finishRun( id: runId, finishedAtUtc: DateTime.parse(finishedAt), @@ -281,30 +288,49 @@ class SyncEngine { row.localCreated); } - Future _markMissingRows( - Set seenTaskListIds, - Map> seenTaskIdsByList, - ) async { + Future _reconcileTaskListMembership(Set seenTaskListIds) async { final lists = await _database.taskListsDao.listTaskLists(_accountId); - for (final list in lists) { - if (!seenTaskListIds.contains(list.id) && !list.localDirty) { + await _database.transaction(() async { + for (final list in lists) { + if (seenTaskListIds.contains(list.id)) { + if (list.serverMissing) { + await (_database.update(_database.taskLists)..where( + (row) => + row.accountId.equals(_accountId) & row.id.equals(list.id), + )) + .write(const TaskListsCompanion(serverMissing: Value(false))); + } + continue; + } + if (list.localDirty) continue; await (_database.update(_database.taskLists)..where( (row) => row.accountId.equals(_accountId) & row.id.equals(list.id), )) .write(const TaskListsCompanion(serverMissing: Value(true))); + await (_database.update(_database.tasks)..where( + (row) => + row.accountId.equals(_accountId) & + row.taskListId.equals(list.id) & + row.localDirty.equals(false), + )) + .write(const TasksCompanion(serverMissing: Value(true))); } - } + }); + } - for (final list in lists) { - final seenTaskIds = seenTaskIdsByList[list.id] ?? const {}; - final tasks = await _database.tasksDao.listTasks(_accountId, list.id); + Future _markMissingTasks( + Map> seenTaskIdsByList, + ) async { + for (final entry in seenTaskIdsByList.entries) { + final seenTaskIds = entry.value; + final tasks = await _database.tasksDao.listTasks(_accountId, entry.key); for (final task in tasks) { if (!seenTaskIds.contains(task.id) && !task.localDirty) { await (_database.update(_database.tasks)..where( (row) => row.accountId.equals(_accountId) & - row.taskListId.equals(list.id) & + row.taskListId.equals(entry.key) & row.id.equals(task.id), )) .write(const TasksCompanion(serverMissing: Value(true))); @@ -313,7 +339,7 @@ class SyncEngine { } } - Future _updateAccountSyncTimestamps(String mode, String timestamp) { + Future _updateAccountSyncCompletion(String mode, String timestamp) { final update = _database.update(_database.accounts) ..where((row) => row.id.equals(_accountId)); return update.write( diff --git a/lib/src/features/task_lists/data/task_lists_repository.dart b/lib/src/features/task_lists/data/task_lists_repository.dart index 5de218e5..0ae681ad 100644 --- a/lib/src/features/task_lists/data/task_lists_repository.dart +++ b/lib/src/features/task_lists/data/task_lists_repository.dart @@ -5,7 +5,9 @@ import 'package:uuid/uuid.dart'; import '../../../db/app_database.dart'; import '../../../dav/mutation/dav_task_list_mutation_service.dart'; -import '../../../providers/busy_provider.dart'; +import '../../accounts/data/accounts_repository.dart'; +import '../../accounts/domain/account_collection_creation_capabilities.dart'; +import '../../notifications/notification_schedule_service.dart'; import '../../tasks/domain/task_remote_client.dart'; import '../../tasks/domain/task_remote_models.dart'; @@ -17,6 +19,7 @@ class TaskListEntity { required this.localDirty, required this.pendingDelete, required this.rawJson, + this.remindersEnabled = true, this.updatedUtc, this.etag, this.providerListKind, @@ -33,6 +36,7 @@ class TaskListEntity { localDirty: row.localDirty, pendingDelete: row.pendingDelete, rawJson: row.rawJson, + remindersEnabled: row.remindersEnabled, updatedUtc: row.updatedUtc, etag: row.etag, providerListKind: row.providerListKind, @@ -48,6 +52,7 @@ class TaskListEntity { final bool localDirty; final bool pendingDelete; final String rawJson; + final bool remindersEnabled; final String? updatedUtc; final String? etag; final String? providerListKind; @@ -69,6 +74,7 @@ class TaskListsRepository { TaskRemoteClient? apiClient, DavTaskListMutationClient? davMutationClient, void Function()? onMutationQueued, + Future Function()? onNotificationScheduleChanged, Uuid uuid = const Uuid(), DateTime Function()? nowUtc, }) : _database = database, @@ -76,6 +82,7 @@ class TaskListsRepository { _apiClient = apiClient, _davMutationClient = davMutationClient, _onMutationQueued = onMutationQueued, + _onNotificationScheduleChanged = onNotificationScheduleChanged, _uuid = uuid, _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()); @@ -84,6 +91,7 @@ class TaskListsRepository { final TaskRemoteClient? _apiClient; final DavTaskListMutationClient? _davMutationClient; final void Function()? _onMutationQueued; + final Future Function()? _onNotificationScheduleChanged; final Uuid _uuid; final DateTime Function() _nowUtc; @@ -116,9 +124,23 @@ class TaskListsRepository { } Future createTaskList(String title) async { - if (await _usesNextcloudDav()) { - await _requiredDavMutationClient().createTaskList(title); - return; + final trimmedTitle = title.trim(); + if (trimmedTitle.isEmpty) { + throw ArgumentError.value( + title, + 'title', + 'A task-list name is required.', + ); + } + final mode = await _taskListCreationMode(); + switch (mode) { + case TaskListCreationMode.nextcloudDav: + await _requiredDavMutationClient().createTaskList(trimmedTitle); + return; + case TaskListCreationMode.unavailable: + throw StateError('Task-list creation is unavailable for this account.'); + case TaskListCreationMode.cloudPendingOperation: + break; } final now = _now(); final localId = 'local-tasklist-${_uuid.v4()}'; @@ -127,8 +149,8 @@ class TaskListsRepository { TaskListsCompanion.insert( accountId: _accountId, id: localId, - title: title, - rawJson: jsonEncode({'id': localId, 'title': title}), + title: trimmedTitle, + rawJson: jsonEncode({'id': localId, 'title': trimmedTitle}), localDirty: const Value(true), createdLocalAtUtc: now, updatedLocalAtUtc: now, @@ -138,7 +160,7 @@ class TaskListsRepository { operation: 'create_task_list', taskListId: localId, localTempId: localId, - request: {'title': title}, + request: {'title': trimmedTitle}, createdAtUtc: now, ); }); @@ -251,6 +273,26 @@ class TaskListsRepository { ); } + Future setRemindersEnabled(String id, bool enabled) async { + final updated = + await (_database.update(_database.taskLists)..where( + (row) => + row.accountId.equals(_accountId) & + row.id.equals(id) & + row.pendingDelete.equals(false) & + row.serverMissing.equals(false), + )) + .write(TaskListsCompanion(remindersEnabled: Value(enabled))); + if (updated != 1) { + throw StateError('The task list is no longer available.'); + } + await NotificationScheduleService( + database: _database, + nowUtc: _nowUtc, + ).rebuildUpcomingTaskNotifications(_accountId); + await _onNotificationScheduleChanged?.call(); + } + Future _updateLocalList(String id, TaskListsCompanion companion) { final update = _database.update(_database.taskLists) ..where((row) => row.accountId.equals(_accountId) & row.id.equals(id)); @@ -290,13 +332,16 @@ class TaskListsRepository { .getSingleOrNull(); } - Future _usesNextcloudDav() async { + Future _taskListCreationMode() async { final account = await (_database.select( _database.accounts, )..where((row) => row.id.equals(_accountId))).getSingleOrNull(); - return account != null && - BusyProviderCodec.requireStorageValue(account.provider) == - BusyProvider.nextcloud; + if (account == null) return TaskListCreationMode.unavailable; + final entity = AccountEntity.fromRow(account); + if (!entity.tasksEnabled || !entity.isSignedIn) { + return TaskListCreationMode.unavailable; + } + return accountCollectionCreationModes(entity.provider).taskListMode; } DavTaskListMutationClient _requiredDavMutationClient() { diff --git a/lib/src/features/tasks/data/tasks_repository.dart b/lib/src/features/tasks/data/tasks_repository.dart index cd762bb9..3f0ca4f4 100644 --- a/lib/src/features/tasks/data/tasks_repository.dart +++ b/lib/src/features/tasks/data/tasks_repository.dart @@ -824,27 +824,38 @@ class TasksRepository { return; } final now = _now(); - final baseline = await _baselineRow(taskListId, taskId); - await _writeLocalTask( - taskListId, - taskId, - TasksCompanion( - pendingDelete: const Value(true), - localDirty: const Value(true), - updatedLocalAtUtc: Value(now), - ), - ); - await _enqueue( - operation: 'delete_task', - taskListId: taskListId, - taskId: taskId, - request: const {}, - baselineUpdatedUtc: baseline?.updatedUtc, - baselineRawJson: baseline?.rawJson, - createdAtUtc: now, - ); + var queuedRemoteDelete = false; + await _database.transaction(() async { + final pendingCreate = await _pendingUnsentTaskCreate(taskId); + if (pendingCreate != null) { + await _cancelPendingTaskCreation(pendingCreate); + return; + } + final baseline = await _baselineRow(taskListId, taskId); + await _writeLocalTask( + taskListId, + taskId, + TasksCompanion( + pendingDelete: const Value(true), + localDirty: const Value(true), + updatedLocalAtUtc: Value(now), + ), + ); + await _enqueue( + operation: 'delete_task', + taskListId: taskListId, + taskId: taskId, + request: const {}, + baselineUpdatedUtc: baseline?.updatedUtc, + baselineRawJson: baseline?.rawJson, + createdAtUtc: now, + ); + queuedRemoteDelete = true; + }); await _rebuildTaskNotifications(); - _onMutationQueued?.call(); + if (queuedRemoteDelete) { + _onMutationQueued?.call(); + } } Future moveTask(TaskMoveInput input) async { @@ -2342,7 +2353,7 @@ class TasksRepository { }) async { final operationId = _uuid.v4(); await _database.transaction(() async { - final predecessor = await _latestPendingTaskEdit( + final predecessor = await _latestPendingTaskOperation( operation: operation, taskListId: taskListId, taskId: taskId, @@ -2368,41 +2379,101 @@ class TasksRepository { return operationId; } - Future _latestPendingTaskEdit({ + Future _latestPendingTaskOperation({ required String operation, required String? taskListId, required String? taskId, }) async { - if ((operation != 'patch_task' && operation != 'update_task') || + if (!_taskMutationOperations.contains(operation) || taskListId == null || taskId == null) { return null; } + final pendingCreate = await _pendingTaskCreate(taskId); + final pendingLocalId = pendingCreate?.localTempId ?? pendingCreate?.taskId; final query = _database.select(_database.pendingOps) ..where( (row) => row.accountId.equals(_accountId) & row.entityType.equals('task') & - row.taskListId.equals(taskListId) & - row.taskId.equals(taskId) & - (row.operation.equals('patch_task') | - row.operation.equals('update_task')), + row.operation.isIn(_taskMutationPredecessorOperations) & + (pendingLocalId == null + ? row.taskListId.equals(taskListId) & row.taskId.equals(taskId) + : row.taskId.equals(pendingLocalId) | + row.localTempId.equals(pendingLocalId)), ) ..orderBy([ (row) => OrderingTerm.desc(row.createdAtUtc), (row) => OrderingTerm.desc(row.updatedAtUtc), ]); - final edits = await query.get(); + final operations = await query.get(); final predecessorIds = { - for (final edit in edits) - if (edit.dependsOnOpId != null) edit.dependsOnOpId!, + for (final operation in operations) + if (operation.dependsOnOpId != null) operation.dependsOnOpId!, }; - for (final edit in edits) { - if (!predecessorIds.contains(edit.id)) { - return edit; + for (final operation in operations) { + if (!predecessorIds.contains(operation.id)) { + return operation; + } + } + return operations.isEmpty ? null : operations.first; + } + + Future _pendingTaskCreate(String taskId) { + final query = _database.select(_database.pendingOps) + ..where( + (row) => + row.accountId.equals(_accountId) & + row.entityType.equals('task') & + row.operation.equals('create_task') & + (row.taskId.equals(taskId) | row.localTempId.equals(taskId)), + ) + ..orderBy([(row) => OrderingTerm.asc(row.createdAtUtc)]); + return query.get().then((operations) => operations.firstOrNull); + } + + Future _pendingUnsentTaskCreate(String taskId) async { + final create = await _pendingTaskCreate(taskId); + return create != null && + create.state == 'pending' && + create.attemptCount == 0 + ? create + : null; + } + + Future _cancelPendingTaskCreation(PendingOp create) async { + final localTaskId = create.localTempId ?? create.taskId; + if (localTaskId == null) return; + final operations = await (_database.select( + _database.pendingOps, + )..where((row) => row.accountId.equals(_accountId))).get(); + final discardedIds = {create.id}; + var changed = true; + while (changed) { + changed = false; + for (final operation in operations) { + final targetsLocalTask = + (operation.entityType == 'task' || + operation.entityType == 'task_checklist_item') && + (operation.taskId == localTaskId || + operation.localTempId == localTaskId); + if (discardedIds.contains(operation.id) || + (!targetsLocalTask && + !discardedIds.contains(operation.dependsOnOpId))) { + continue; + } + discardedIds.add(operation.id); + changed = true; } } - return edits.isEmpty ? null : edits.first; + await (_database.delete( + _database.pendingOps, + )..where((row) => row.id.isIn(discardedIds))).go(); + await (_database.delete(_database.tasks)..where( + (row) => + row.accountId.equals(_accountId) & row.id.equals(localTaskId), + )) + .go(); } Future _baselineRow(String taskListId, String taskId) { @@ -2540,6 +2611,18 @@ class TasksRepository { String _now() => _nowUtc().toIso8601String(); } +const _taskMutationOperations = { + 'patch_task', + 'update_task', + 'move_task', + 'delete_task', +}; + +const _taskMutationPredecessorOperations = { + 'create_task', + ..._taskMutationOperations, +}; + TaskChecklistItemEntity taskChecklistItemFromDto(TaskChecklistItemDto dto) { final raw = { ...dto.rawJson, diff --git a/lib/src/features/tasks/domain/task_capabilities.dart b/lib/src/features/tasks/domain/task_capabilities.dart index a1411054..5272c63f 100644 --- a/lib/src/features/tasks/domain/task_capabilities.dart +++ b/lib/src/features/tasks/domain/task_capabilities.dart @@ -240,4 +240,5 @@ TaskCollectionCapabilities adapterDefaultTaskCapabilities( BusyProvider.microsoft => microsoftTaskCollectionCapabilities, BusyProvider.appleICloud => noTaskCollectionCapabilities, BusyProvider.nextcloud => nextcloudTaskCollectionCapabilities, + BusyProvider.webCal => noTaskCollectionCapabilities, }; diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index a8734636..16b255d9 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -7,6 +7,7 @@ import 'package:busymax/src/app/busymax_surface_colors.dart'; import 'package:busymax/src/core/time/local_time_zone.dart'; import 'package:busymax/src/core/time/time_zone_catalog.dart'; import 'package:busymax/src/l10n/l10n.dart'; +import 'package:busymax/src/l10n/localized_formatters.dart'; import 'package:busymax/src/features/schedule/presentation/mini_calendar.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_anchored_popover.dart'; import 'package:busymax/src/features/tasks/presentation/time_zone_selection_dialog.dart'; @@ -445,7 +446,7 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { Widget _buildDateModeHeader(BuildContext context) { final locale = Localizations.localeOf(context).toLanguageTag(); - final monthLabel = DateFormat.MMMM(locale).format(_displayedMonth); + final monthLabel = localizedMonthHeading(locale, _displayedMonth); final colorScheme = Theme.of(context).colorScheme; final direction = Directionality.of(context); diff --git a/lib/src/features/tasks/presentation/ical_task_fields_editor.dart b/lib/src/features/tasks/presentation/ical_task_fields_editor.dart index ad3afd33..dc330189 100644 --- a/lib/src/features/tasks/presentation/ical_task_fields_editor.dart +++ b/lib/src/features/tasks/presentation/ical_task_fields_editor.dart @@ -2,16 +2,16 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:intl/intl.dart'; import 'package:yaru/yaru.dart'; import 'package:busymax/l10n/generated/app_localizations.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../dav/ical/ical_task_alarm.dart'; -import '../../../dav/ical/ical_task_recurrence.dart'; import '../../../l10n/l10n.dart'; import '../../../platform/linux_header_bar_service.dart'; +import '../../recurrence/domain/recurrence_rule.dart'; +import '../../recurrence/presentation/recurrence_editor.dart'; import '../domain/task_capabilities.dart'; import 'desktop_date_time_fields.dart'; import 'task_details_draft.dart'; @@ -363,7 +363,7 @@ class _IcalTaskFieldsEditorState extends State { Widget _recurrenceGroup() { final l10n = context.l10n; - final recurrence = IcalTaskRecurrence.fromJson( + final recurrence = RecurrenceRule.fromJson( widget.draft.recurrenceJson, baseDate: _recurrenceBaseDate, ); @@ -532,28 +532,28 @@ class _IcalTaskFieldsEditorState extends State { } Future _editRecurrence() async { - final initial = IcalTaskRecurrence.fromJson( + final initial = RecurrenceRule.fromJson( widget.draft.recurrenceJson, baseDate: _recurrenceBaseDate, ); if (!initial.isSupported) return; - final result = await showBusyMaxModalEditorDialog( + final baseDate = _recurrenceBaseDate ?? DateTime.now(); + final allDay = + widget.draft.microsoftDueTime == null && + widget.draft.microsoftStartTime == null; + final result = await showRecurrenceEditorDialog( context, + initial: initial, + allDay: allDay, + floating: + !allDay && + (widget.draft.microsoftStartTimeZone?.trim().isEmpty ?? true), + baseDate: baseDate, + minimumDate: baseDate, + timeZone: widget.draft.microsoftStartTimeZone, + useNativeDatePicker: widget.useNativeDatePicker, barrierColor: widget.dialogBarrierColor, headerBarService: widget.headerBarService, - maxWidth: 620, - maxHeight: 820, - builder: (context) => _TaskRecurrenceDialog( - initial: initial, - allDay: - widget.draft.microsoftDueTime == null && - widget.draft.microsoftStartTime == null, - baseDate: DateTime.now(), - minimumDate: - DateTime.tryParse(widget.draft.microsoftStartDate ?? '') ?? - DateTime.now(), - useNativeDatePicker: widget.useNativeDatePicker, - ), ); if (result == null || !mounted) return; widget.onChanged( @@ -613,75 +613,15 @@ class _IcalTaskFieldsEditorState extends State { return alarm.action; } - String _recurrenceSummary(IcalTaskRecurrence recurrence) { - if (!recurrence.repeats) return context.l10n.repeatNone; - final l10n = context.l10n; - var summary = recurrence.interval == 1 - ? switch (recurrence.frequency) { - IcalTaskRecurrenceFrequency.daily => l10n.repeatDaily, - IcalTaskRecurrenceFrequency.weekly => l10n.repeatWeekly, - IcalTaskRecurrenceFrequency.monthly => l10n.repeatMonthly, - IcalTaskRecurrenceFrequency.yearly => l10n.repeatYearly, - IcalTaskRecurrenceFrequency.none => l10n.repeatNone, - } - : switch (recurrence.frequency) { - IcalTaskRecurrenceFrequency.daily => l10n.repeatEveryDays( - recurrence.interval, - ), - IcalTaskRecurrenceFrequency.weekly => l10n.repeatEveryWeeks( - recurrence.interval, - ), - IcalTaskRecurrenceFrequency.monthly => l10n.repeatEveryMonths( - recurrence.interval, - ), - IcalTaskRecurrenceFrequency.yearly => l10n.repeatEveryYears( - recurrence.interval, - ), - IcalTaskRecurrenceFrequency.none => l10n.repeatNone, - }; - if (recurrence.frequency == IcalTaskRecurrenceFrequency.weekly && - recurrence.byDay.isNotEmpty) { - summary = - '$summary ${l10n.repeatOnDaysSummary(recurrence.byDay.map(_weekdayLabel).join(', '))}'; - } else if ((recurrence.frequency == IcalTaskRecurrenceFrequency.monthly || - recurrence.frequency == IcalTaskRecurrenceFrequency.yearly) && - recurrence.byMonthDay.isNotEmpty) { - summary = - '$summary ${l10n.repeatOnMonthDaysSummary(recurrence.byMonthDay.join(', '))}'; - } else if (recurrence.bySetPosition case final position?) { - summary = - '$summary ${l10n.repeatOnOrdinalSummary(_ordinalLabel(position), _ordinalDayLabel(recurrence.byDay))}'; - } - if (recurrence.frequency == IcalTaskRecurrenceFrequency.yearly && - recurrence.byMonth.isNotEmpty) { - summary = - '$summary ${l10n.repeatInMonthsSummary(recurrence.byMonth.map(_monthLabel).join(', '))}'; - } - if (recurrence.count != null) { - return '$summary · ${l10n.repeatTimesSummary(recurrence.count!)}'; - } - final until = recurrence.untilDate; - if (until == null) return summary; - final date = DateTime.tryParse(until); - final label = date == null - ? until - : MaterialLocalizations.of(context).formatMediumDate(date); - return '$summary · ${l10n.repeatUntilSummary(label)}'; - } + String _recurrenceSummary(RecurrenceRule recurrence) => recurrenceRuleSummary( + context, + recurrence, + timeZone: widget.draft.microsoftStartTimeZone, + ); DateTime? get _recurrenceBaseDate => DateTime.tryParse( widget.draft.microsoftStartDate ?? widget.draft.dueDate ?? '', ); - - String _weekdayLabel(String day) => - _localizedWeekday(context, day, abbreviated: false); - - String _monthLabel(int month) => _localizedMonth(context, month); - - String _ordinalLabel(int value) => _localizedOrdinal(context, value); - - String _ordinalDayLabel(List days) => - _localizedOrdinalDay(context, days); } class _TaskValueSliderRow extends StatelessWidget { @@ -1028,530 +968,6 @@ class _TaskReminderDialogState extends State<_TaskReminderDialog> { }; } -enum _RecurrenceEnd { never, until, count } - -class _TaskRecurrenceDialog extends StatefulWidget { - const _TaskRecurrenceDialog({ - required this.initial, - required this.allDay, - required this.baseDate, - required this.minimumDate, - required this.useNativeDatePicker, - }); - - final IcalTaskRecurrence initial; - final bool allDay; - final DateTime baseDate; - final DateTime minimumDate; - final bool useNativeDatePicker; - - @override - State<_TaskRecurrenceDialog> createState() => _TaskRecurrenceDialogState(); -} - -class _TaskRecurrenceDialogState extends State<_TaskRecurrenceDialog> { - late IcalTaskRecurrence _value; - late _RecurrenceEnd _end; - late final TextEditingController _intervalController; - late final TextEditingController _countController; - - @override - void initState() { - super.initState(); - _value = widget.initial.isSupported - ? widget.initial - : const IcalTaskRecurrence.none(); - _end = _value.count != null - ? _RecurrenceEnd.count - : _value.untilRaw != null - ? _RecurrenceEnd.until - : _RecurrenceEnd.never; - _intervalController = TextEditingController(text: '${_value.interval}'); - _countController = TextEditingController(text: '${_value.count ?? 10}'); - } - - @override - void dispose() { - _intervalController.dispose(); - _countController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final repeats = _value.repeats; - return BusyMaxModalEditorScaffold( - title: l10n.repeat, - cancelLabel: l10n.cancel, - saveLabel: l10n.save, - onCancel: () => Navigator.pop(context), - onSave: _isValid ? () => Navigator.pop(context, _value) : null, - contentMaxWidth: 560, - children: [ - BusyMaxGroupedList( - filled: true, - children: [ - BusyMaxComboRow( - title: l10n.repeat, - values: IcalTaskRecurrenceFrequency.values, - selected: _value.frequency, - labelFor: _frequencyLabel, - onSelected: _setFrequency, - ), - if (repeats) - YaruListTile.square( - title: TextField( - controller: _intervalController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: busyMaxGroupedTextFieldDecoration( - context, - labelText: l10n.repeatEvery, - ), - onChanged: (value) { - final parsed = int.tryParse(value); - setState(() { - if (parsed != null && parsed >= 1 && parsed <= 366) { - _value = _value.copyWith(interval: parsed); - } - }); - }, - ), - ), - ], - ), - if (_value.frequency == IcalTaskRecurrenceFrequency.weekly) - BusyMaxGroupedList( - title: l10n.repeatOn, - filled: true, - children: [ - Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.md), - child: YaruChoiceChipBar( - style: YaruChoiceChipBarStyle.wrap, - labels: [ - for (final day in _weekdays) Text(_weekdayLabel(day)), - ], - isSelected: [ - for (final day in _weekdays) _value.byDay.contains(day), - ], - selectedFirst: false, - clearOnSelect: false, - onSelected: (index) { - final day = _weekdays[index]; - _toggleWeekday(day, !_value.byDay.contains(day)); - }, - ), - ), - ], - ), - if (_value.frequency == IcalTaskRecurrenceFrequency.monthly || - _value.frequency == IcalTaskRecurrenceFrequency.yearly) - BusyMaxGroupedList( - title: l10n.repeatDayOfMonth, - filled: true, - children: [ - Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.md), - child: YaruChoiceChipBar( - style: YaruChoiceChipBarStyle.wrap, - labels: [for (var day = 1; day <= 31; day += 1) Text('$day')], - isSelected: [ - for (var day = 1; day <= 31; day += 1) - _value.byMonthDay.contains(day), - ], - selectedFirst: false, - clearOnSelect: false, - onSelected: _value.bySetPosition == null - ? (index) { - final day = index + 1; - _toggleMonthDay( - day, - !_value.byMonthDay.contains(day), - ); - } - : null, - ), - ), - BusyMaxComboRow( - title: l10n.repeatOrdinal, - values: const [0, ..._ordinalPositions], - selected: _value.bySetPosition ?? 0, - labelFor: (value) => value == 0 - ? l10n.repeatSpecificDays - : _localizedOrdinal(context, value), - onSelected: _setOrdinal, - ), - if (_value.bySetPosition != null) - BusyMaxComboRow( - title: l10n.repeatOn, - values: [for (final choice in _ordinalDayChoices) choice.key], - selected: _ordinalDayChoiceKey(_value.byDay), - labelFor: (value) => _localizedOrdinalDay( - context, - _ordinalDayChoices - .firstWhere((choice) => choice.key == value) - .days, - ), - onSelected: (value) { - final days = _ordinalDayChoices - .firstWhere((choice) => choice.key == value) - .days; - setState(() => _value = _value.copyWith(byDay: days)); - }, - ), - ], - ), - if (_value.frequency == IcalTaskRecurrenceFrequency.yearly) - BusyMaxGroupedList( - title: l10n.repeatMonths, - filled: true, - children: [ - Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.md), - child: YaruChoiceChipBar( - style: YaruChoiceChipBarStyle.wrap, - labels: [ - for (var month = 1; month <= 12; month += 1) - Text(_localizedMonth(context, month)), - ], - isSelected: [ - for (var month = 1; month <= 12; month += 1) - _value.byMonth.contains(month), - ], - selectedFirst: false, - clearOnSelect: false, - onSelected: (index) { - final month = index + 1; - _toggleMonth(month, !_value.byMonth.contains(month)); - }, - ), - ), - ], - ), - if (repeats) - BusyMaxGroupedList( - filled: true, - children: [ - BusyMaxComboRow<_RecurrenceEnd>( - title: l10n.repeatEnd, - values: _RecurrenceEnd.values, - selected: _end, - labelFor: (value) => switch (value) { - _RecurrenceEnd.never => l10n.repeatNever, - _RecurrenceEnd.until => l10n.repeatUntil, - _RecurrenceEnd.count => l10n.repeatAfter, - }, - onSelected: _setEnd, - ), - if (_end == _RecurrenceEnd.until) - DesktopDateValueRow( - label: l10n.repeatUntil, - date: - _value.untilDate ?? - _dateString(_oneMonthAfter(DateTime.now())), - useNativePicker: widget.useNativeDatePicker, - onChanged: _setUntilDate, - ), - if (_end == _RecurrenceEnd.count) - YaruListTile.square( - title: TextField( - controller: _countController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: busyMaxGroupedTextFieldDecoration( - context, - labelText: l10n.repeatCount, - ), - onChanged: (value) { - final parsed = int.tryParse(value); - setState(() { - if (parsed != null && parsed >= 1 && parsed <= 3500) { - _value = _value.copyWith(count: parsed); - } - }); - }, - ), - ), - ], - ), - ], - ); - } - - bool get _isValid { - if (!_value.repeats) return true; - final interval = int.tryParse(_intervalController.text); - if (interval == null || interval < 1 || interval > 366) return false; - if (_value.frequency == IcalTaskRecurrenceFrequency.weekly && - _value.byDay.isEmpty) { - return false; - } - if ((_value.frequency == IcalTaskRecurrenceFrequency.monthly || - _value.frequency == IcalTaskRecurrenceFrequency.yearly) && - _value.byMonthDay.isEmpty && - (_value.bySetPosition == null || _value.byDay.isEmpty)) { - return false; - } - if (_value.frequency == IcalTaskRecurrenceFrequency.yearly && - _value.byMonth.isEmpty) { - return false; - } - if (_end == _RecurrenceEnd.until && _value.untilRaw == null) return false; - if (_end == _RecurrenceEnd.count) { - final count = int.tryParse(_countController.text); - if (count == null || count < 1 || count > 3500) return false; - } - return true; - } - - void _setFrequency(IcalTaskRecurrenceFrequency frequency) { - final now = DateTime.now(); - setState(() { - _value = switch (frequency) { - IcalTaskRecurrenceFrequency.none => _value.copyWith( - frequency: frequency, - byDay: const [], - byMonth: const [], - byMonthDay: const [], - bySetPosition: null, - count: null, - untilRaw: null, - ), - IcalTaskRecurrenceFrequency.weekly => _value.copyWith( - frequency: frequency, - byDay: [_weekdayCode(now.weekday)], - byMonth: const [], - byMonthDay: const [], - bySetPosition: null, - ), - IcalTaskRecurrenceFrequency.monthly => _value.copyWith( - frequency: frequency, - byDay: const [], - byMonth: const [], - byMonthDay: [now.day], - bySetPosition: null, - ), - IcalTaskRecurrenceFrequency.yearly => _value.copyWith( - frequency: frequency, - byDay: const [], - byMonth: [now.month], - byMonthDay: [now.day], - bySetPosition: null, - ), - IcalTaskRecurrenceFrequency.daily => _value.copyWith( - frequency: frequency, - byDay: const [], - byMonth: const [], - byMonthDay: const [], - bySetPosition: null, - ), - }; - }); - } - - void _toggleWeekday(String day, bool selected) { - final values = [..._value.byDay]; - if (selected) { - if (!values.contains(day)) values.add(day); - } else if (values.length > 1) { - values.remove(day); - } - values.sort( - (left, right) => - _weekdays.indexOf(left).compareTo(_weekdays.indexOf(right)), - ); - setState(() => _value = _value.copyWith(byDay: values)); - } - - void _toggleMonthDay(int day, bool selected) { - final values = [..._value.byMonthDay]; - if (selected) { - if (!values.contains(day)) values.add(day); - } else if (values.length > 1) { - values.remove(day); - } - values.sort(); - setState(() { - _value = _value.copyWith( - byMonthDay: values, - byDay: const [], - bySetPosition: null, - ); - }); - } - - void _toggleMonth(int month, bool selected) { - final values = [..._value.byMonth]; - if (selected) { - if (!values.contains(month)) values.add(month); - } else if (values.length > 1) { - values.remove(month); - } - values.sort(); - setState(() => _value = _value.copyWith(byMonth: values)); - } - - void _setOrdinal(int? value) { - setState(() { - if (value == null || value == 0) { - _value = _value.copyWith( - bySetPosition: null, - byDay: const [], - byMonthDay: _value.byMonthDay.isEmpty - ? [widget.baseDate.day] - : _value.byMonthDay, - ); - } else { - _value = _value.copyWith( - bySetPosition: value, - byDay: _value.byDay.isEmpty - ? [_weekdayCode(widget.baseDate.weekday)] - : _value.byDay, - byMonthDay: const [], - ); - } - }); - } - - void _setEnd(_RecurrenceEnd value) { - setState(() { - _end = value; - _value = switch (value) { - _RecurrenceEnd.never => _value.copyWith(count: null, untilRaw: null), - _RecurrenceEnd.count => _value.copyWith( - count: int.tryParse(_countController.text) ?? 10, - untilRaw: null, - ), - _RecurrenceEnd.until => - _value - .copyWith(count: null) - .withUntilDate( - _value.untilDate ?? _dateString(_oneMonthAfter(DateTime.now())), - allDay: widget.allDay, - ), - }; - }); - } - - void _setUntilDate(String value) { - final date = DateTime.tryParse(value); - if (date == null) return; - final firstDate = DateTime( - widget.minimumDate.year, - widget.minimumDate.month, - widget.minimumDate.day, - ); - if (date.isBefore(firstDate)) return; - setState(() { - _value = _value.withUntilDate(_dateString(date), allDay: widget.allDay); - }); - } - - String _frequencyLabel(IcalTaskRecurrenceFrequency frequency) => - switch (frequency) { - IcalTaskRecurrenceFrequency.none => context.l10n.repeatNone, - IcalTaskRecurrenceFrequency.daily => context.l10n.repeatDaily, - IcalTaskRecurrenceFrequency.weekly => context.l10n.repeatWeekly, - IcalTaskRecurrenceFrequency.monthly => context.l10n.repeatMonthly, - IcalTaskRecurrenceFrequency.yearly => context.l10n.repeatYearly, - }; - - String _weekdayLabel(String day) { - return _localizedWeekday(context, day, abbreviated: true); - } -} - -const _weekdays = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']; -const _ordinalPositions = [1, 2, 3, 4, 5, -2, -1]; - -typedef _OrdinalDayChoice = ({String key, List days}); - -const _ordinalDayChoices = <_OrdinalDayChoice>[ - (key: 'MO', days: ['MO']), - (key: 'TU', days: ['TU']), - (key: 'WE', days: ['WE']), - (key: 'TH', days: ['TH']), - (key: 'FR', days: ['FR']), - (key: 'SA', days: ['SA']), - (key: 'SU', days: ['SU']), - (key: 'day', days: ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']), - (key: 'weekday', days: ['MO', 'TU', 'WE', 'TH', 'FR']), - (key: 'weekend', days: ['SA', 'SU']), -]; - -String _weekdayCode(int weekday) => _weekdays[(weekday - 1).clamp(0, 6)]; - -String _ordinalDayChoiceKey(List days) { - for (final choice in _ordinalDayChoices) { - if (_sameDaySet(choice.days, days)) return choice.key; - } - return days.firstOrNull ?? 'MO'; -} - -String _localizedWeekday( - BuildContext context, - String day, { - required bool abbreviated, -}) { - final index = _weekdays.indexOf(day); - if (index < 0) return day; - final date = DateTime(2024, 1, 1 + index); - final locale = Localizations.localeOf(context).toLanguageTag(); - return DateFormat(abbreviated ? 'EEE' : 'EEEE', locale).format(date); -} - -String _localizedMonth(BuildContext context, int month) { - if (month < 1 || month > 12) return '$month'; - return DateFormat( - 'MMM', - Localizations.localeOf(context).toLanguageTag(), - ).format(DateTime(2024, month)); -} - -String _localizedOrdinal(BuildContext context, int value) => switch (value) { - 1 => context.l10n.repeatFirst, - 2 => context.l10n.repeatSecond, - 3 => context.l10n.repeatThird, - 4 => context.l10n.repeatFourth, - 5 => context.l10n.repeatFifth, - -2 => context.l10n.repeatSecondToLast, - -1 => context.l10n.repeatLast, - _ => '$value', -}; - -String _localizedOrdinalDay(BuildContext context, List days) { - if (days.length == 1) { - return _localizedWeekday(context, days.single, abbreviated: false); - } - if (_sameDaySet(days, _ordinalDayChoices[7].days)) { - return context.l10n.repeatAnyDay; - } - if (_sameDaySet(days, _ordinalDayChoices[8].days)) { - return context.l10n.repeatWeekday; - } - if (_sameDaySet(days, _ordinalDayChoices[9].days)) { - return context.l10n.repeatWeekendDay; - } - return days - .map((day) => _localizedWeekday(context, day, abbreviated: false)) - .join(', '); -} - -bool _sameDaySet(List left, List right) { - if (left.length != right.length) return false; - final leftValues = {...left}; - return leftValues.length == left.length && leftValues.containsAll(right); -} - -DateTime _oneMonthAfter(DateTime value) { - final firstOfTarget = DateTime(value.year, value.month + 1); - final lastDay = DateTime(firstOfTarget.year, firstOfTarget.month + 1, 0).day; - final day = value.day > lastDay ? lastDay : value.day; - return DateTime(firstOfTarget.year, firstOfTarget.month, day); -} - String _dateString(DateTime value) => '${value.year.toString().padLeft(4, '0')}-' '${value.month.toString().padLeft(2, '0')}-' diff --git a/lib/src/features/tasks/presentation/new_task_dialog.dart b/lib/src/features/tasks/presentation/new_task_dialog.dart index 5333d6f7..acc47990 100644 --- a/lib/src/features/tasks/presentation/new_task_dialog.dart +++ b/lib/src/features/tasks/presentation/new_task_dialog.dart @@ -8,6 +8,7 @@ import '../../../app/busymax_dialogs.dart'; import '../../../google_tasks/api/google_tasks_json.dart'; import '../../../l10n/l10n.dart'; import '../../../platform/linux_header_bar_service.dart'; +import '../../../platform/linux_header_bar_provider.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import '../../accounts/data/accounts_repository.dart'; diff --git a/lib/src/features/tasks/presentation/task_details_pane.dart b/lib/src/features/tasks/presentation/task_details_pane.dart index 267d2115..41a159fe 100644 --- a/lib/src/features/tasks/presentation/task_details_pane.dart +++ b/lib/src/features/tasks/presentation/task_details_pane.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../l10n/l10n.dart'; +import '../../../platform/linux_header_bar_provider.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import '../../accounts/data/accounts_repository.dart'; @@ -177,13 +178,16 @@ class _TaskDetailsPaneState extends ConsumerState { )), ) .valueOrNull, - BusyProvider.google || BusyProvider.microsoft => null, + BusyProvider.google || + BusyProvider.microsoft || + BusyProvider.webCal => null, }; final capabilities = switch (account.provider) { BusyProvider.appleICloud || BusyProvider.nextcloud => davCapabilities ?? noTaskCollectionCapabilities, BusyProvider.google || BusyProvider.microsoft => adapterDefaultTaskCapabilities(account.provider), + BusyProvider.webCal => noTaskCollectionCapabilities, }; return StreamBuilder( @@ -776,6 +780,7 @@ String _providerEditorLabel(BuildContext context, BusyProvider provider) { BusyProvider.microsoft => l10n.microsoftTodoProvider, BusyProvider.appleICloud => 'Apple iCloud', BusyProvider.nextcloud => 'Nextcloud Tasks', + BusyProvider.webCal => 'WebCal', }; } diff --git a/lib/src/features/tray/data/tray_presentation_service.dart b/lib/src/features/tray/data/tray_presentation_service.dart new file mode 100644 index 00000000..83c32dda --- /dev/null +++ b/lib/src/features/tray/data/tray_presentation_service.dart @@ -0,0 +1,203 @@ +import '../../../app/app_settings.dart'; +import '../../../schedule/schedule_filters.dart'; +import '../../../schedule/schedule_item.dart'; +import '../../../schedule/schedule_range.dart'; +import '../../../schedule/schedule_repository.dart'; +import '../../../schedule/schedule_source_visibility.dart'; +import '../../accounts/data/accounts_repository.dart'; +import '../../calendar/data/calendar_repository.dart'; +import '../../connectivity/network_connectivity_service.dart'; +import '../../task_lists/data/task_lists_repository.dart'; +import '../domain/tray_presentation.dart'; + +typedef TrayTaskListsLoader = + Future> Function(AccountEntity account); +typedef TrayTaskListWriteCheck = + Future Function(AccountEntity account, TaskListEntity taskList); + +final class BusyMaxTrayPresentationService { + const BusyMaxTrayPresentationService({ + required AccountsRepository accountsRepository, + required CalendarRepository calendarRepository, + required ScheduleRepository scheduleRepository, + required TrayTaskListsLoader listTaskLists, + required TrayTaskListWriteCheck canCreateTask, + required AppSettings Function() settings, + required NetworkAvailability Function() connectivity, + required bool Function() synchronizationRunning, + DateTime Function()? now, + }) : _accountsRepository = accountsRepository, + _calendarRepository = calendarRepository, + _scheduleRepository = scheduleRepository, + _listTaskLists = listTaskLists, + _canCreateTask = canCreateTask, + _settings = settings, + _connectivity = connectivity, + _synchronizationRunning = synchronizationRunning, + _now = now ?? DateTime.now; + + final AccountsRepository _accountsRepository; + final CalendarRepository _calendarRepository; + final ScheduleRepository _scheduleRepository; + final TrayTaskListsLoader _listTaskLists; + final TrayTaskListWriteCheck _canCreateTask; + final AppSettings Function() _settings; + final NetworkAvailability Function() _connectivity; + final bool Function() _synchronizationRunning; + final DateTime Function() _now; + + Future load() async { + final now = _now(); + final accounts = await _accountsRepository.watchAccounts().first; + final accountIds = accounts.map((account) => account.id).toList(); + final sources = await _calendarRepository + .watchSourcesForAccounts(accountIds) + .first; + final taskLists = []; + for (final account in accounts.where((account) => account.isTaskCapable)) { + taskLists.addAll(await _listTaskLists(account)); + } + final settings = _settings(); + final visibility = ScheduleSourceVisibility.fromSources( + calendarSources: sources, + taskLists: taskLists, + settings: settings, + ); + final items = await _scheduleRepository.listItems( + range: ScheduleRange.day(now), + filters: ScheduleFilters( + accountIds: accountIds.toSet(), + sourceIds: visibility.visibleCalendarSourceIds, + taskListKeys: visibility.visibleTaskListKeys, + sourceFilterActive: true, + taskListFilterActive: true, + showCompletedTasks: false, + showNoDateTasks: false, + ), + ); + final writableTaskLists = {}; + final accountsById = {for (final account in accounts) account.id: account}; + for (final taskList in taskLists) { + final key = ScheduleTaskListKey( + accountId: taskList.accountId, + taskListId: taskList.id, + ); + if (!visibility.visibleTaskListKeys.contains(key)) continue; + final account = accountsById[taskList.accountId]; + if (account != null && await _canCreateTask(account, taskList)) { + writableTaskLists.add(key); + } + } + return projectBusyMaxTrayPresentation( + accounts: accounts, + calendarSources: sources, + taskLists: taskLists, + scheduleItems: items, + writableTaskListKeys: writableTaskLists, + settings: settings, + connectivity: _connectivity(), + synchronizationRunning: _synchronizationRunning(), + now: now, + ); + } +} + +BusyMaxTrayPresentation projectBusyMaxTrayPresentation({ + required List accounts, + required List calendarSources, + required List taskLists, + required List scheduleItems, + required Set writableTaskListKeys, + required AppSettings settings, + required NetworkAvailability connectivity, + required bool synchronizationRunning, + required DateTime now, +}) { + final visibility = ScheduleSourceVisibility.fromSources( + calendarSources: calendarSources, + taskLists: taskLists, + settings: settings, + ); + final eventEntries = []; + var taskCount = 0; + for (final item in scheduleItems) { + if (item is CalendarScheduleItem) { + if (eventEntries.length >= 3 || + !visibility.visibleCalendarSourceIds.contains(item.sourceId)) { + continue; + } + final start = item.start; + if (start == null || !_trayEventIsRelevant(item, now)) continue; + eventEntries.add( + BusyMaxTrayEventEntry( + eventId: item.id, + accountId: item.accountId, + calendarSourceId: item.sourceId, + title: item.title, + start: start, + end: item.end, + allDay: item.allDay, + ), + ); + continue; + } + if (item is TaskScheduleItem && + !item.completed && + item.start != null && + visibility.visibleTaskListKeys.contains( + ScheduleTaskListKey( + accountId: item.accountId, + taskListId: item.sourceId, + ), + )) { + taskCount += 1; + } + } + + final visibleWritableTaskList = writableTaskListKeys.any( + visibility.visibleTaskListKeys.contains, + ); + final syncEligible = accounts.where((account) => account.isSyncEligible); + DateTime? oldestSuccessfulSync; + var allAccountsHaveSynchronized = true; + var hasSyncEligibleAccount = false; + for (final account in syncEligible) { + hasSyncEligibleAccount = true; + final synchronizedAt = account.lastSuccessfulSyncAtUtc; + if (synchronizedAt == null) { + allAccountsHaveSynchronized = false; + continue; + } + if (oldestSuccessfulSync == null || + synchronizedAt.isBefore(oldestSuccessfulSync)) { + oldestSuccessfulSync = synchronizedAt; + } + } + return BusyMaxTrayPresentation( + connectivity: connectivity, + synchronizationRunning: synchronizationRunning, + lastSuccessfulSynchronizationUtc: allAccountsHaveSynchronized + ? oldestSuccessfulSync + : null, + events: List.unmodifiable(eventEntries), + incompleteTasksDueToday: taskCount, + canCreateEvent: calendarSources.any( + (source) => + visibility.visibleCalendarSourceIds.contains(source.id) && + source.capabilities.canCreateEvents, + ), + canCreateTask: visibleWritableTaskList, + hasSyncEligibleAccount: hasSyncEligibleAccount, + notificationDetailLevel: settings.notificationDetailLevel, + localNow: now, + ); +} + +bool _trayEventIsRelevant(CalendarScheduleItem event, DateTime now) { + if (event.allDay) return true; + final start = event.start; + if (start == null) return false; + final end = event.end; + if (end == null) return !start.isBefore(now); + return end.isAfter(now); +} diff --git a/lib/src/features/tray/domain/tray_presentation.dart b/lib/src/features/tray/domain/tray_presentation.dart new file mode 100644 index 00000000..a8dc369b --- /dev/null +++ b/lib/src/features/tray/domain/tray_presentation.dart @@ -0,0 +1,214 @@ +import 'package:collection/collection.dart'; + +import '../../../app/app_settings.dart'; +import '../../connectivity/network_connectivity_service.dart'; + +final class BusyMaxTrayEventEntry { + const BusyMaxTrayEventEntry({ + required this.eventId, + required this.accountId, + required this.calendarSourceId, + required this.title, + required this.start, + required this.end, + required this.allDay, + }); + + final String eventId; + final String accountId; + final String calendarSourceId; + final String title; + final DateTime start; + final DateTime? end; + final bool allDay; + + @override + bool operator ==(Object other) => + other is BusyMaxTrayEventEntry && + other.eventId == eventId && + other.accountId == accountId && + other.calendarSourceId == calendarSourceId && + other.title == title && + other.start == start && + other.end == end && + other.allDay == allDay; + + @override + int get hashCode => Object.hash( + eventId, + accountId, + calendarSourceId, + title, + start, + end, + allDay, + ); +} + +final class BusyMaxTrayPresentation { + const BusyMaxTrayPresentation({ + required this.connectivity, + required this.synchronizationRunning, + required this.lastSuccessfulSynchronizationUtc, + required this.events, + required this.incompleteTasksDueToday, + required this.canCreateEvent, + required this.canCreateTask, + required this.hasSyncEligibleAccount, + required this.notificationDetailLevel, + required this.localNow, + }); + + final NetworkAvailability connectivity; + final bool synchronizationRunning; + final DateTime? lastSuccessfulSynchronizationUtc; + final List events; + final int incompleteTasksDueToday; + final bool canCreateEvent; + final bool canCreateTask; + final bool hasSyncEligibleAccount; + final NotificationDetailLevel notificationDetailLevel; + final DateTime localNow; + + @override + bool operator ==(Object other) => + other is BusyMaxTrayPresentation && + other.connectivity == connectivity && + other.synchronizationRunning == synchronizationRunning && + other.lastSuccessfulSynchronizationUtc == + lastSuccessfulSynchronizationUtc && + const ListEquality().equals( + other.events, + events, + ) && + other.incompleteTasksDueToday == incompleteTasksDueToday && + other.canCreateEvent == canCreateEvent && + other.canCreateTask == canCreateTask && + other.hasSyncEligibleAccount == hasSyncEligibleAccount && + other.notificationDetailLevel == notificationDetailLevel && + other.localNow == localNow; + + @override + int get hashCode => Object.hash( + connectivity, + synchronizationRunning, + lastSuccessfulSynchronizationUtc, + const ListEquality().hash(events), + incompleteTasksDueToday, + canCreateEvent, + canCreateTask, + hasSyncEligibleAccount, + notificationDetailLevel, + localNow, + ); +} + +final class BusyMaxTrayEventRowPresentation { + const BusyMaxTrayEventRowPresentation({ + required this.event, + required this.label, + }); + + final BusyMaxTrayEventEntry event; + final String label; + + @override + bool operator ==(Object other) => + other is BusyMaxTrayEventRowPresentation && + other.event == event && + other.label == label; + + @override + int get hashCode => Object.hash(event, label); +} + +final class BusyMaxTrayMenuPresentation { + const BusyMaxTrayMenuPresentation({ + required this.showBusyMaxLabel, + required this.newEventLabel, + required this.newTaskLabel, + required this.todayLabel, + required this.eventRows, + required this.taskSummaryLabel, + required this.emptyStateLabel, + required this.openTodayAgendaLabel, + required this.syncNowLabel, + required this.synchronizationStatusLabel, + required this.settingsLabel, + required this.quitBusyMaxLabel, + required this.offlineTitle, + required this.offlineDescription, + required this.canCreateEvent, + required this.canCreateTask, + required this.canSynchronize, + required this.offline, + }); + + final String showBusyMaxLabel; + final String newEventLabel; + final String newTaskLabel; + final String todayLabel; + final List eventRows; + final String? taskSummaryLabel; + final String emptyStateLabel; + final String openTodayAgendaLabel; + final String syncNowLabel; + final String synchronizationStatusLabel; + final String settingsLabel; + final String quitBusyMaxLabel; + final String offlineTitle; + final String offlineDescription; + final bool canCreateEvent; + final bool canCreateTask; + final bool canSynchronize; + final bool offline; + + bool get showEmptyState => eventRows.isEmpty && taskSummaryLabel == null; + + @override + bool operator ==(Object other) => + other is BusyMaxTrayMenuPresentation && + other.showBusyMaxLabel == showBusyMaxLabel && + other.newEventLabel == newEventLabel && + other.newTaskLabel == newTaskLabel && + other.todayLabel == todayLabel && + const ListEquality().equals( + other.eventRows, + eventRows, + ) && + other.taskSummaryLabel == taskSummaryLabel && + other.emptyStateLabel == emptyStateLabel && + other.openTodayAgendaLabel == openTodayAgendaLabel && + other.syncNowLabel == syncNowLabel && + other.synchronizationStatusLabel == synchronizationStatusLabel && + other.settingsLabel == settingsLabel && + other.quitBusyMaxLabel == quitBusyMaxLabel && + other.offlineTitle == offlineTitle && + other.offlineDescription == offlineDescription && + other.canCreateEvent == canCreateEvent && + other.canCreateTask == canCreateTask && + other.canSynchronize == canSynchronize && + other.offline == offline; + + @override + int get hashCode => Object.hashAll([ + showBusyMaxLabel, + newEventLabel, + newTaskLabel, + todayLabel, + const ListEquality().hash(eventRows), + taskSummaryLabel, + emptyStateLabel, + openTodayAgendaLabel, + syncNowLabel, + synchronizationStatusLabel, + settingsLabel, + quitBusyMaxLabel, + offlineTitle, + offlineDescription, + canCreateEvent, + canCreateTask, + canSynchronize, + offline, + ]); +} diff --git a/lib/src/features/tray/domain/tray_presentation_formatter.dart b/lib/src/features/tray/domain/tray_presentation_formatter.dart new file mode 100644 index 00000000..757a8742 --- /dev/null +++ b/lib/src/features/tray/domain/tray_presentation_formatter.dart @@ -0,0 +1,156 @@ +import '../../../app/app_settings.dart'; +import '../../connectivity/network_connectivity_service.dart'; +import 'tray_presentation.dart'; + +final class BusyMaxTrayPresentationStrings { + const BusyMaxTrayPresentationStrings({ + required this.showBusyMax, + required this.newEvent, + required this.newTask, + required this.today, + required this.allDay, + required this.now, + required this.calendarEvent, + required this.untitledEvent, + required this.nothingElseToday, + required this.openTodayAgenda, + required this.syncNow, + required this.syncing, + required this.notConnected, + required this.notYetSynced, + required this.settings, + required this.quitBusyMax, + required this.offline, + required this.offlineDescription, + required this.formatTime, + required this.tasksDueToday, + required this.lastSyncedJustNow, + required this.lastSyncedMinutesAgo, + required this.lastSyncedHoursAgo, + required this.lastSyncedDaysAgo, + }); + + final String showBusyMax; + final String newEvent; + final String newTask; + final String today; + final String allDay; + final String now; + final String calendarEvent; + final String untitledEvent; + final String nothingElseToday; + final String openTodayAgenda; + final String syncNow; + final String syncing; + final String notConnected; + final String notYetSynced; + final String settings; + final String quitBusyMax; + final String offline; + final String offlineDescription; + final String Function(DateTime value) formatTime; + final String Function(int count) tasksDueToday; + final String lastSyncedJustNow; + final String Function(int count) lastSyncedMinutesAgo; + final String Function(int count) lastSyncedHoursAgo; + final String Function(int count) lastSyncedDaysAgo; +} + +final class BusyMaxTrayPresentationFormatter { + const BusyMaxTrayPresentationFormatter(this.strings); + + final BusyMaxTrayPresentationStrings strings; + + BusyMaxTrayMenuPresentation format(BusyMaxTrayPresentation presentation) { + final eventRows = [ + for (final event in presentation.events) + BusyMaxTrayEventRowPresentation( + event: event, + label: _eventLabel(event, presentation), + ), + ]; + final offline = presentation.connectivity == NetworkAvailability.offline; + return BusyMaxTrayMenuPresentation( + showBusyMaxLabel: strings.showBusyMax, + newEventLabel: strings.newEvent, + newTaskLabel: strings.newTask, + todayLabel: strings.today, + eventRows: List.unmodifiable(eventRows), + taskSummaryLabel: presentation.incompleteTasksDueToday == 0 + ? null + : strings.tasksDueToday(presentation.incompleteTasksDueToday), + emptyStateLabel: strings.nothingElseToday, + openTodayAgendaLabel: strings.openTodayAgenda, + syncNowLabel: strings.syncNow, + synchronizationStatusLabel: _synchronizationStatus(presentation), + settingsLabel: strings.settings, + quitBusyMaxLabel: strings.quitBusyMax, + offlineTitle: strings.offline, + offlineDescription: strings.offlineDescription, + canCreateEvent: presentation.canCreateEvent, + canCreateTask: presentation.canCreateTask, + canSynchronize: + presentation.connectivity == NetworkAvailability.online && + presentation.hasSyncEligibleAccount && + !presentation.synchronizationRunning, + offline: offline, + ); + } + + String _eventLabel( + BusyMaxTrayEventEntry event, + BusyMaxTrayPresentation presentation, + ) { + final prefix = event.allDay + ? strings.allDay + : _isInProgress(event, presentation.localNow) + ? strings.now + : strings.formatTime(event.start); + final title = + presentation.notificationDetailLevel == NotificationDetailLevel.private + ? strings.calendarEvent + : _sanitizeTitle(event.title); + return _truncate('$prefix · $title', 100); + } + + String _sanitizeTitle(String source) { + final withoutControls = source.replaceAll( + RegExp(r'[\u0000-\u001f\u007f-\u009f]'), + ' ', + ); + final collapsed = withoutControls.replaceAll(RegExp(r'\s+'), ' ').trim(); + return collapsed.isEmpty ? strings.untitledEvent : collapsed; + } + + String _synchronizationStatus(BusyMaxTrayPresentation presentation) { + if (presentation.connectivity == NetworkAvailability.offline) { + return '${strings.offline} — ${strings.offlineDescription}'; + } + if (presentation.synchronizationRunning) return strings.syncing; + if (!presentation.hasSyncEligibleAccount) return strings.notConnected; + final lastSync = presentation.lastSuccessfulSynchronizationUtc; + if (lastSync == null) return strings.notYetSynced; + final elapsed = presentation.localNow.toUtc().difference(lastSync); + if (elapsed <= Duration.zero || elapsed.inMinutes < 1) { + return strings.lastSyncedJustNow; + } + if (elapsed.inHours < 1) { + return strings.lastSyncedMinutesAgo(elapsed.inMinutes); + } + if (elapsed.inDays < 1) { + return strings.lastSyncedHoursAgo(elapsed.inHours); + } + return strings.lastSyncedDaysAgo(elapsed.inDays); + } +} + +bool _isInProgress(BusyMaxTrayEventEntry event, DateTime now) { + final end = event.end; + return !event.start.isAfter(now) && end != null && end.isAfter(now); +} + +String _truncate(String value, int maximumRunes) { + final runes = value.runes.toList(growable: false); + if (runes.length <= maximumRunes) return value; + return '${String.fromCharCodes(runes.take(maximumRunes - 1))}…'; +} diff --git a/lib/src/google_calendar/google_calendar_api_client.dart b/lib/src/google_calendar/google_calendar_api_client.dart index 973e5e84..f71b36a0 100644 --- a/lib/src/google_calendar/google_calendar_api_client.dart +++ b/lib/src/google_calendar/google_calendar_api_client.dart @@ -11,7 +11,8 @@ import 'google_calendar_errors.dart'; import 'google_calendar_mapper.dart'; import 'google_calendar_models.dart'; -class GoogleCalendarApiClient implements CloudCalendarClient { +class GoogleCalendarApiClient + implements CloudCalendarClient, CalendarListManagementClient { GoogleCalendarApiClient({ required http.Client httpClient, required Uri baseUri, @@ -71,6 +72,12 @@ class GoogleCalendarApiClient implements CloudCalendarClient { _uri('/calendar/v3/calendars'), body: googleCalendarMutationToJson(mutation), ); + final calendarId = json['id']?.toString(); + if (calendarId != null && + calendarId.isNotEmpty && + _hasCalendarListColor(mutation)) { + return _updateCalendarListColor(calendarId, mutation); + } return googleCalendarSourceFromJson(json); } @@ -79,21 +86,63 @@ class GoogleCalendarApiClient implements CloudCalendarClient { String calendarId, CalendarMutation mutation, ) async { + var updatedGlobalMetadata = false; + final calendarBody = googleCalendarMutationToJson(mutation); + if (calendarBody.isNotEmpty) { + await _requestJson( + 'PATCH', + _uri('/calendar/v3/calendars/${_enc(calendarId)}'), + body: calendarBody, + ); + updatedGlobalMetadata = true; + } + if (_hasCalendarListColor(mutation)) { + return _updateCalendarListColor(calendarId, mutation); + } + if (!updatedGlobalMetadata) { + throw ArgumentError('Calendar update has no writable fields.'); + } + return _getCalendarListEntry(calendarId); + } + + Future _getCalendarListEntry(String calendarId) async { + final json = await _requestJson( + 'GET', + _uri('/calendar/v3/users/me/calendarList/${_enc(calendarId)}'), + ); + return googleCalendarSourceFromJson(json); + } + + Future _updateCalendarListColor( + String calendarId, + CalendarMutation mutation, + ) async { + final usesRgb = + mutation.backgroundColor != null || mutation.foregroundColor != null; final json = await _requestJson( 'PATCH', - _uri('/calendar/v3/calendars/${_enc(calendarId)}'), - body: googleCalendarMutationToJson(mutation), + _uri( + '/calendar/v3/users/me/calendarList/${_enc(calendarId)}', + query: usesRgb ? const {'colorRgbFormat': 'true'} : null, + ), + body: googleCalendarListColorMutationToJson(mutation), ); return googleCalendarSourceFromJson(json); } + @override Future updateCalendarListEntry( String calendarId, CalendarMutation mutation, ) async { + final usesRgb = + mutation.backgroundColor != null || mutation.foregroundColor != null; final json = await _requestJson( 'PATCH', - _uri('/calendar/v3/users/me/calendarList/${_enc(calendarId)}'), + _uri( + '/calendar/v3/users/me/calendarList/${_enc(calendarId)}', + query: usesRgb ? const {'colorRgbFormat': 'true'} : null, + ), body: googleCalendarListMutationToJson(mutation), ); return googleCalendarSourceFromJson(json); @@ -108,6 +157,7 @@ class GoogleCalendarApiClient implements CloudCalendarClient { return googleCalendarSourceFromJson(json); } + @override Future deleteCalendarListEntry(String calendarId) { return _requestEmpty( 'DELETE', @@ -161,6 +211,7 @@ class GoogleCalendarApiClient implements CloudCalendarClient { '/calendar/v3/calendars/${_enc(calendarId)}/events', query: { 'conferenceDataVersion': '1', + 'supportsAttachments': 'true', 'sendUpdates': _googleGuestUpdatePolicy(guestUpdatePolicy), }, ), @@ -309,17 +360,23 @@ class GoogleCalendarApiClient implements CloudCalendarClient { return events; } + @override Future moveEvent({ required String sourceCalendarId, required String eventId, required String destinationCalendarId, + CalendarGuestUpdatePolicy guestUpdatePolicy = + CalendarGuestUpdatePolicy.send, }) async { final json = await _requestJson( 'POST', _uri( '/calendar/v3/calendars/${_enc(sourceCalendarId)}/events/' '${_enc(eventId)}/move', - query: {'destination': destinationCalendarId}, + query: { + 'destination': destinationCalendarId, + 'sendUpdates': _googleGuestUpdatePolicy(guestUpdatePolicy), + }, ), ); return googleCalendarEventFromJson(destinationCalendarId, json); @@ -535,6 +592,12 @@ class GoogleCalendarApiClient implements CloudCalendarClient { } } +bool _hasCalendarListColor(CalendarMutation mutation) { + return mutation.backgroundColor != null || + mutation.foregroundColor != null || + mutation.colorId != null; +} + String _enc(String value) => Uri.encodeComponent(value); String _rfc3339(DateTime value) => value.toUtc().toIso8601String(); diff --git a/lib/src/google_calendar/google_calendar_mapper.dart b/lib/src/google_calendar/google_calendar_mapper.dart index 2ffb5ce0..023cb920 100644 --- a/lib/src/google_calendar/google_calendar_mapper.dart +++ b/lib/src/google_calendar/google_calendar_mapper.dart @@ -6,11 +6,15 @@ import 'package:busymax/src/providers/busy_provider.dart'; CalendarSourceDto googleCalendarSourceFromJson(Map json) { final accessRole = json['accessRole']?.toString(); + final summaryOverride = json['summaryOverride']?.toString().trim(); + final providerSummary = json['summary']?.toString().trim(); return CalendarSourceDto( provider: BusyProvider.google, providerCalendarId: json['id']?.toString() ?? '', - summary: json['summary']?.toString().trim().isNotEmpty == true - ? json['summary']!.toString() + summary: summaryOverride?.isNotEmpty == true + ? summaryOverride! + : providerSummary?.isNotEmpty == true + ? providerSummary! : 'Calendar', description: json['description']?.toString(), primaryCalendar: json['primary'] == true, @@ -22,6 +26,7 @@ CalendarSourceDto googleCalendarSourceFromJson(Map json) { colorId: json['colorId']?.toString(), timeZone: json['timeZone']?.toString(), accessRole: accessRole, + dataOwner: json['dataOwner']?.toString(), isDeleted: json['deleted'] == true, rawJson: json, ); @@ -97,6 +102,16 @@ Map googleCalendarListMutationToJson( }); } +Map googleCalendarListColorMutationToJson( + CalendarMutation mutation, +) { + return _compact({ + 'backgroundColor': mutation.backgroundColor, + 'foregroundColor': mutation.foregroundColor, + 'colorId': mutation.colorId, + }); +} + Map googleEventMutationToJson(CalendarEventMutation mutation) { final allDay = mutation.allDay ?? mutation.startDate != null; final start = allDay @@ -112,26 +127,58 @@ Map googleEventMutationToJson(CalendarEventMutation mutation) { 'timeZone': mutation.endTimeZone, }); - return _compact({ - 'summary': mutation.title, - 'description': mutation.description, - 'location': mutation.location, - if (start.isNotEmpty) 'start': start, - if (end.isNotEmpty) 'end': end, - 'recurrence': mutation.clearRecurrence - ? const [] - : mutation.recurrence, - 'reminders': mutation.reminders, - 'attendees': mutation.clearAttendees - ? const [] - : mutation.attendees, - 'colorId': mutation.colorId, - 'visibility': mutation.visibility, - 'transparency': mutation.transparencyOrShowAs, - if (mutation.hideAttendees != null) - 'guestsCanSeeOtherGuests': !mutation.hideAttendees!, - 'conferenceData': mutation.conference, - }); + return { + ..._googleWritableEventFields(mutation.providerRaw), + ..._compact({ + 'id': mutation.providerEventId, + 'summary': mutation.title, + 'description': mutation.description, + 'location': mutation.location, + if (start.isNotEmpty) 'start': start, + if (end.isNotEmpty) 'end': end, + 'recurrence': mutation.clearRecurrence + ? const [] + : mutation.recurrence, + 'reminders': mutation.reminders, + 'attendees': mutation.clearAttendees + ? const [] + : mutation.attendees, + 'colorId': mutation.colorId, + 'visibility': mutation.visibility, + 'transparency': mutation.transparencyOrShowAs, + if (mutation.hideAttendees != null) + 'guestsCanSeeOtherGuests': !mutation.hideAttendees!, + 'conferenceData': mutation.conference, + }), + }; +} + +Map _googleWritableEventFields(Map? source) { + if (source == null) return const {}; + const writable = { + 'summary', + 'description', + 'location', + 'start', + 'end', + 'recurrence', + 'attendees', + 'reminders', + 'attachments', + 'colorId', + 'visibility', + 'transparency', + 'guestsCanInviteOthers', + 'guestsCanModify', + 'guestsCanSeeOtherGuests', + 'extendedProperties', + 'source', + 'conferenceData', + }; + return { + for (final entry in source.entries) + if (writable.contains(entry.key)) entry.key: entry.value, + }; } String jsonOrNull(Object? value) { diff --git a/lib/src/google_tasks/oauth/oauth_loopback_flow.dart b/lib/src/google_tasks/oauth/oauth_loopback_flow.dart index 44abf7cf..391f37ab 100644 --- a/lib/src/google_tasks/oauth/oauth_loopback_flow.dart +++ b/lib/src/google_tasks/oauth/oauth_loopback_flow.dart @@ -363,12 +363,7 @@ Uri _redactedAuthorizationUri(Uri uri) { } Future _defaultAuthorizationLauncher(Uri authorizationUri) async { - if (await launchUrl(authorizationUri, mode: LaunchMode.externalApplication)) { - return true; - } - - await Process.start('xdg-open', [authorizationUri.toString()]); - return true; + return launchUrl(authorizationUri, mode: LaunchMode.externalApplication); } Future _writeBrowserResponse(HttpResponse response) async { diff --git a/lib/src/ical/ical_event_projection.dart b/lib/src/ical/ical_event_projection.dart new file mode 100644 index 00000000..091793ad --- /dev/null +++ b/lib/src/ical/ical_event_projection.dart @@ -0,0 +1,298 @@ +import 'dart:convert'; + +import '../dav/dav_errors.dart'; +import '../dav/ical/ical_document.dart'; +import '../dav/ical/ical_recurrence.dart'; +import '../dav/ical/ical_semantics.dart'; +import '../dav/ical/ical_timezone.dart'; +import 'ical_ingestion.dart'; + +const sharedIcalEventProjectionVersion = 1; + +final class ProjectedIcalEvent { + const ProjectedIcalEvent({ + required this.uid, + required this.occurrenceKey, + required this.recurrenceIdKey, + required this.title, + required this.description, + required this.location, + required this.allDay, + required this.startDate, + required this.startDateTime, + required this.startTimeZone, + required this.endDate, + required this.endDateTime, + required this.endTimeZone, + required this.recurrenceJson, + required this.remindersJson, + required this.attendeesJson, + required this.categoriesJson, + required this.organizerJson, + required this.visibility, + required this.transparency, + required this.status, + required this.cancelled, + required this.recurring, + required this.webLink, + required this.attachmentsJson, + required this.createdAtServer, + required this.updatedAtServer, + required this.rawJson, + }); + + final String uid; + final String occurrenceKey; + final String? recurrenceIdKey; + final String title; + final String? description; + final String? location; + final bool allDay; + final String? startDate; + final String? startDateTime; + final String? startTimeZone; + final String? endDate; + final String? endDateTime; + final String? endTimeZone; + final String recurrenceJson; + final String remindersJson; + final String attendeesJson; + final String categoriesJson; + final String? organizerJson; + final String? visibility; + final String? transparency; + final String? status; + final bool cancelled; + final bool recurring; + final String? webLink; + final String attachmentsJson; + final String? createdAtServer; + final String? updatedAtServer; + final String rawJson; +} + +final class IcalEventProjector { + IcalEventProjector({IcalRecurrenceExpander? recurrenceExpander}) + : _recurrenceExpander = recurrenceExpander ?? IcalRecurrenceExpander(); + + final IcalRecurrenceExpander _recurrenceExpander; + + List project( + IcalRecurrenceSet set, { + required DateTime rangeStartUtc, + required DateTime rangeEndUtc, + required String transport, + }) { + return projectSemantic( + set.semantic, + rangeStartUtc: rangeStartUtc, + rangeEndUtc: rangeEndUtc, + transport: transport, + ); + } + + List projectSemantic( + IcalSemanticDocument semantic, { + required DateTime rangeStartUtc, + required DateTime rangeEndUtc, + required String transport, + }) { + final occurrences = _recurrenceExpander.expand( + semantic, + rangeStartUtc: rangeStartUtc, + rangeEndUtc: rangeEndUtc, + ); + final resolver = IcalTimeZoneResolver.fromDocument(semantic); + final recurringUids = { + for (final component in semantic.components) + if (component.recurrenceId != null || + component.recurrenceRules.isNotEmpty || + component.recurrenceDates.isNotEmpty || + component.exceptionDates.isNotEmpty) + component.uid, + }; + return [ + for (final occurrence in occurrences) + _projectOccurrence( + occurrence, + resolver, + transport, + recurring: recurringUids.contains(occurrence.master.uid), + ), + ]; + } +} + +ProjectedIcalEvent _projectOccurrence( + IcalOccurrence occurrence, + IcalTimeZoneResolver resolver, + String transport, { + required bool recurring, +}) { + final component = occurrence.effectiveComponent; + final master = occurrence.master; + final identity = occurrence.identityComponent; + final allDay = occurrence.start.kind == IcalTemporalKind.date; + final attendees = component.attendees.isEmpty + ? master.attendees + : component.attendees; + final organizers = component.organizers.isEmpty + ? master.organizers + : component.organizers; + final categories = component.categories.isEmpty + ? master.categories + : component.categories; + final alarms = component.alarms.isEmpty ? master.alarms : component.alarms; + final startUtc = _resolvedUtc(occurrence.start, resolver); + final endUtc = occurrence.end == null + ? null + : _resolvedUtc(occurrence.end!, resolver); + final attachments = _propertyRawValues(component, 'ATTACH'); + final masterAttachments = _propertyRawValues(master, 'ATTACH'); + return ProjectedIcalEvent( + uid: component.uid!, + occurrenceKey: occurrence.occurrenceKey, + recurrenceIdKey: identity.recurrenceIdKey, + title: occurrence.summary ?? '', + description: occurrence.description, + location: occurrence.location, + allDay: allDay, + startDate: allDay ? _storageTemporal(occurrence.start) : null, + startDateTime: allDay ? null : _storageTemporal(occurrence.start), + startTimeZone: _timeZoneName(occurrence.start), + endDate: allDay && occurrence.end != null + ? _storageTemporal(occurrence.end!) + : null, + endDateTime: !allDay && occurrence.end != null + ? _storageTemporal(occurrence.end!) + : null, + endTimeZone: occurrence.end == null ? null : _timeZoneName(occurrence.end!), + recurrenceJson: jsonEncode({ + 'rules': master.recurrenceRules, + 'dates': master.recurrenceDates, + 'excludedDates': master.exceptionDates, + }), + remindersJson: jsonEncode({ + 'minutes': _eventReminderMinutes(alarms), + 'alarms': _alarmProjection(alarms), + }), + attendeesJson: jsonEncode(attendees), + categoriesJson: jsonEncode(categories), + organizerJson: organizers.isEmpty ? null : jsonEncode(organizers.first), + visibility: component.classification ?? master.classification, + transparency: component.transparency ?? master.transparency, + status: component.status ?? master.status, + cancelled: occurrence.isCancelled, + recurring: recurring, + webLink: _propertyRaw(component, 'URL') ?? _propertyRaw(master, 'URL'), + attachmentsJson: jsonEncode( + attachments.isEmpty ? masterAttachments : attachments, + ), + createdAtServer: _storageTemporalNullable( + component.created ?? master.created, + ), + updatedAtServer: _storageTemporalNullable( + component.lastModified ?? master.lastModified, + ), + rawJson: jsonEncode({ + 'transport': transport, + 'uid': component.uid, + 'occurrenceKey': occurrence.occurrenceKey, + 'nativeStart': _temporalJson(occurrence.start), + if (occurrence.end != null) 'nativeEnd': _temporalJson(occurrence.end!), + if (startUtc != null) 'startUtc': startUtc.toIso8601String(), + if (endUtc != null) 'endUtc': endUtc.toIso8601String(), + if (transport == 'caldav') + 'extensionProperties': { + ...master.extensionProperties, + ...component.extensionProperties, + }, + }), + ); +} + +String? _storageTemporalNullable(IcalTemporalValue? value) => + value == null ? null : _storageTemporal(value); + +String? _propertyRaw(IcalSemanticComponent component, String name) => + component.documentComponent.firstProperty(name)?.rawValue; + +List _propertyRawValues(IcalSemanticComponent component, String name) => + component.documentComponent + .propertiesNamed(name) + .map((property) => property.rawValue) + .toList(growable: false); + +String _storageTemporal(IcalTemporalValue value) { + String two(int number) => number.toString().padLeft(2, '0'); + final wall = value.localValue; + final date = + '${wall.year.toString().padLeft(4, '0')}-' + '${two(wall.month)}-${two(wall.day)}'; + if (value.kind == IcalTemporalKind.date) return date; + if (value.kind == IcalTemporalKind.utcDateTime) { + return icalTemporalToUtc(value).toIso8601String(); + } + return '${date}T${two(wall.hour)}:${two(wall.minute)}:${two(wall.second)}'; +} + +String? _timeZoneName(IcalTemporalValue value) => switch (value.kind) { + IcalTemporalKind.utcDateTime => 'UTC', + IcalTemporalKind.tzidDateTime => value.timeZoneId, + IcalTemporalKind.date || IcalTemporalKind.floatingDateTime => null, +}; + +DateTime? _resolvedUtc(IcalTemporalValue value, IcalTimeZoneResolver resolver) { + return switch (value.kind) { + IcalTemporalKind.utcDateTime || IcalTemporalKind.tzidDateTime => + icalTemporalToUtc(value, resolver: resolver), + IcalTemporalKind.date || IcalTemporalKind.floatingDateTime => null, + }; +} + +Map _temporalJson(IcalTemporalValue value) => { + 'raw': value.rawValue, + 'kind': value.kind.name, + if (value.timeZoneId != null) 'timeZoneId': value.timeZoneId, +}; + +List> _alarmProjection(List alarms) => [ + for (final alarm in alarms) + { + 'properties': [ + for (final property in alarm.properties) + { + 'name': property.name, + 'value': property.rawValue, + if (property.parameters.isNotEmpty) + 'parameters': [ + for (final parameter in property.parameters) + {'name': parameter.name, 'values': parameter.values}, + ], + }, + ], + }, +]; + +List _eventReminderMinutes(List alarms) { + final result = []; + for (final alarm in alarms) { + final action = alarm.firstProperty('ACTION')?.rawValue.toUpperCase(); + if (action != 'DISPLAY' && action != 'AUDIO') continue; + final trigger = alarm.firstProperty('TRIGGER'); + if (trigger == null || + trigger.parameterValue('RELATED')?.toUpperCase() == 'END') { + continue; + } + try { + final duration = parseIcalDuration(trigger.rawValue.toUpperCase()); + if (duration == null || !duration.negative) continue; + final before = -duration.duration; + if (before.inSeconds <= 0 || before.inSeconds % 60 != 0) continue; + if (!result.contains(before.inMinutes)) result.add(before.inMinutes); + } on DavException { + // Unsupported trigger forms remain available in the alarm projection. + } + } + return result; +} diff --git a/lib/src/ical/ical_import_service.dart b/lib/src/ical/ical_import_service.dart new file mode 100644 index 00000000..dfcb5aef --- /dev/null +++ b/lib/src/ical/ical_import_service.dart @@ -0,0 +1,445 @@ +import 'package:drift/drift.dart'; + +import '../dav/dav_errors.dart'; +import '../dav/ical/ical_document.dart'; +import '../dav/ical/ical_semantics.dart'; +import '../dav/ical/ical_timezone.dart'; +import '../db/app_database.dart'; +import '../features/calendar/data/calendar_repository.dart'; +import '../features/calendar/presentation/event_editor_draft.dart'; +import '../features/recurrence/domain/event_recurrence_codec.dart'; +import '../providers/busy_provider.dart'; +import 'ical_ingestion.dart'; + +final class IcalImportPreview { + const IcalImportPreview({ + required this.ingestion, + required this.eventCount, + required this.invalidEventCount, + required this.fieldsThatWillBeOmitted, + }); + + final IcalIngestionResult ingestion; + final int eventCount; + final int invalidEventCount; + final Set fieldsThatWillBeOmitted; +} + +final class IcalImportSkippedSet { + const IcalImportSkippedSet({required this.uid, required this.reason}); + + final String uid; + final String reason; +} + +final class IcalImportReport { + const IcalImportReport({ + required this.queued, + required this.duplicatesSkipped, + required this.unsupportedRecurrenceSets, + required this.invalidEvents, + required this.fieldsIntentionallyOmitted, + }); + + final int queued; + final int duplicatesSkipped; + final List unsupportedRecurrenceSets; + final int invalidEvents; + final Set fieldsIntentionallyOmitted; +} + +final class IcalImportService { + IcalImportService({ + required AppDatabase database, + required CalendarRepository calendarRepository, + }) : _database = database, + _calendarRepository = calendarRepository; + + final AppDatabase _database; + final CalendarRepository _calendarRepository; + + IcalImportPreview parsePreview(List bytes) { + final ingestion = IcalIngestion.parseBytes( + bytes, + policy: IcalIngestionPolicy.fileImport, + ); + final omitted = {}; + if (ingestion.document.root.firstProperty('METHOD') != null) { + omitted.add('scheduling method'); + } + for (final set in ingestion.recurrenceSets) { + for (final event in set.semantic.components) { + if (event.attendees.isNotEmpty) omitted.add('attendees'); + if (event.organizers.isNotEmpty) omitted.add('organizer'); + if (event.url != null) omitted.add('URL'); + if (event.documentComponent.propertiesNamed('ATTACH').isNotEmpty) { + omitted.add('attachments'); + } + if (event.documentComponent.propertiesNamed('IMAGE').isNotEmpty) { + omitted.add('images'); + } + if (event.documentComponent.propertiesNamed('SOURCE').isNotEmpty) { + omitted.add('source links'); + } + if (event.documentComponent.propertiesNamed('CONFERENCE').isNotEmpty) { + omitted.add('conference links'); + } + if (event.alarms.any((alarm) => _alarmMinutes([alarm]).isEmpty)) { + omitted.add('unsupported alarms'); + } + } + } + for (final componentType in ingestion.unprojectedComponentTypes) { + omitted.add('$componentType components'); + } + return IcalImportPreview( + ingestion: ingestion, + eventCount: ingestion.recurrenceSets.length, + invalidEventCount: ingestion.rejectedEvents.length, + fieldsThatWillBeOmitted: Set.unmodifiable(omitted), + ); + } + + Future> writableDestinations() async { + final rows = + await (_database.select(_database.calendarSources)..where( + (row) => + row.readOnly.equals(false) & + row.isDeleted.equals(false) & + row.provider.equals(BusyProvider.webCal.storageValue).not(), + )) + .get(); + final pendingIds = { + for (final op + in await (_database.select(_database.pendingOps)..where( + (row) => + row.operationType.equals('calendar.create') & + row.state.isIn(const ['pending', 'retry', 'in_progress']), + )) + .get()) + if (op.calendarSourceId != null) op.calendarSourceId!, + }; + return [ + for (final row in rows) + CalendarSourceEntity.fromRow( + row, + pendingCreate: pendingIds.contains(row.id), + ), + ]; + } + + Future importPreview({ + required IcalImportPreview preview, + required CalendarSourceEntity destination, + }) async { + if (!destination.capabilities.canCreateEvents || + destination.provider == BusyProvider.webCal) { + throw ArgumentError('The selected calendar is not writable.'); + } + final receipts = await (_database.select( + _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 unsupported = []; + var duplicates = 0; + final omitted = {...preview.fieldsThatWillBeOmitted}; + for (final set in preview.ingestion.recurrenceSets) { + if (importedUids.contains(set.uid)) { + duplicates += 1; + continue; + } + final prepared = _prepareDraft(set, destination); + if (prepared.reason != null) { + unsupported.add( + IcalImportSkippedSet(uid: set.uid, reason: prepared.reason!), + ); + continue; + } + omitted.addAll(prepared.omittedFields); + drafts.add((icalUid: set.uid, draft: prepared.draft!)); + } + await _calendarRepository.createImportedEventsBatch( + destination: destination, + events: drafts, + ); + return IcalImportReport( + queued: drafts.length, + duplicatesSkipped: duplicates, + unsupportedRecurrenceSets: List.unmodifiable(unsupported), + invalidEvents: preview.invalidEventCount, + fieldsIntentionallyOmitted: Set.unmodifiable(omitted), + ); + } +} + +_PreparedImportDraft _prepareDraft( + IcalRecurrenceSet set, + CalendarSourceEntity destination, +) { + final semantic = set.semantic; + final masters = semantic.components + .where((component) => component.recurrenceId == null) + .toList(); + if (masters.length != 1) { + return const _PreparedImportDraft.unsupported( + '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) { + return const _PreparedImportDraft.unsupported( + 'BusyMax requires an event summary.', + ); + } + final startValue = master.start; + if (startValue == null) { + return const _PreparedImportDraft.unsupported( + 'The event does not contain DTSTART.', + ); + } + if (startValue.kind == IcalTemporalKind.floatingDateTime) { + return const _PreparedImportDraft.unsupported( + 'Floating date-times cannot be imported without guessing a timezone.', + ); + } + final resolver = IcalTimeZoneResolver.fromDocument(semantic); + final providerTimeZones = IcalTimeZoneResolver.system(); + try { + if (startValue.kind == IcalTemporalKind.tzidDateTime) { + resolver.toUtc(startValue); + providerTimeZones.toUtc(startValue); + } + if (master.end?.kind == IcalTemporalKind.tzidDateTime) { + resolver.toUtc(master.end!); + providerTimeZones.toUtc(master.end!); + } + } on DavException catch (error) { + if (error.code == 'IcalUnknownTimeZone') { + return const _PreparedImportDraft.unsupported( + 'The destination cannot represent this embedded custom timezone.', + ); + } + return _PreparedImportDraft.unsupported(error.safeMessage); + } + + final allDay = startValue.kind == IcalTemporalKind.date; + final start = _draftDateTime(startValue); + final endValue = master.end; + final DateTime end; + String? endTimeZone; + if (endValue != null) { + if (endValue.kind == IcalTemporalKind.floatingDateTime) { + return const _PreparedImportDraft.unsupported( + 'Floating date-times cannot be imported without guessing a timezone.', + ); + } + if ((endValue.kind == IcalTemporalKind.date) != allDay) { + return const _PreparedImportDraft.unsupported( + 'The event start and end use incompatible value types.', + ); + } + end = _draftDateTime(endValue); + endTimeZone = _timeZone(endValue); + } else if (master.duration != null) { + end = _addWallDuration(startValue, master.duration!.duration); + endTimeZone = _timeZone(startValue); + } else if (allDay) { + end = DateTime(start.year, start.month, start.day + 1); + } else { + return const _PreparedImportDraft.unsupported( + 'Zero-duration date-time events cannot be represented by BusyMax.', + ); + } + if (!end.isAfter(start)) { + return const _PreparedImportDraft.unsupported( + 'The event has a non-positive duration.', + ); + } + + final recurrenceMap = { + 'rules': master.recurrenceRules, + 'dates': master.recurrenceDates, + 'excludedDates': master.exceptionDates, + }; + Object? recurrence; + if (master.recurrenceRules.isNotEmpty || + master.recurrenceDates.isNotEmpty || + master.exceptionDates.isNotEmpty) { + if (destination.provider == BusyProvider.microsoft && + (master.recurrenceDates.isNotEmpty || + master.exceptionDates.isNotEmpty)) { + return const _PreparedImportDraft.unsupported( + 'Microsoft cannot represent this event’s RDATE or EXDATE set.', + ); + } + final rule = EventRecurrenceCodec.decode( + BusyProvider.google, + recurrenceMap, + baseDate: start, + ); + if (!rule.repeats || + !EventRecurrenceCodec.canEncode(destination.provider, rule)) { + return _PreparedImportDraft.unsupported( + '${destination.provider.displayName} cannot represent this recurrence rule.', + ); + } + recurrence = EventRecurrenceCodec.encode( + destination.provider, + rule, + baseDate: start, + allDay: allDay, + timeZone: _timeZone(startValue), + original: recurrenceMap, + ); + } + + final reminderMinutes = _alarmMinutes(master.alarms); + final reminders = switch (destination.provider) { + BusyProvider.microsoft when reminderMinutes.isNotEmpty => { + 'isReminderOn': true, + 'reminderMinutesBeforeStart': reminderMinutes.first, + }, + BusyProvider.google || + BusyProvider.appleICloud || + BusyProvider.nextcloud when reminderMinutes.isNotEmpty => { + 'useDefault': false, + 'overrides': [ + for (final minutes in reminderMinutes) + {'method': 'popup', 'minutes': minutes}, + ], + }, + _ => null, + }; + final omitted = {}; + if (destination.provider == BusyProvider.microsoft && + reminderMinutes.length > 1) { + omitted.add('additional reminders'); + } + if (destination.provider == BusyProvider.google && + master.categories.isNotEmpty) { + omitted.add('categories'); + } + final categories = destination.provider == BusyProvider.google + ? const [] + : master.categories; + return _PreparedImportDraft( + draft: + EventEditorDraft.newEvent( + accountId: destination.accountId, + sourceId: destination.id, + providerCalendarId: destination.providerCalendarId, + start: start, + end: end, + ).copyWith( + title: title, + allDay: allDay, + startTimeZone: _timeZone(startValue), + endTimeZone: endTimeZone, + description: master.description, + location: master.location, + recurrence: recurrence, + recurrenceChanged: recurrence != null, + reminders: reminders, + remindersChanged: reminders != null, + attendees: const [], + attendeesChanged: false, + showAs: _transparency(master.transparency, destination.provider), + visibilityOrSensitivity: master.classification?.toLowerCase(), + categories: categories, + categoriesChanged: categories.isNotEmpty, + createConference: false, + clearConference: true, + isOrganizer: true, + ), + reason: null, + omittedFields: omitted, + ); +} + +DateTime _draftDateTime(IcalTemporalValue value) { + final wall = value.localValue; + if (value.kind == IcalTemporalKind.utcDateTime) return wall.toUtc(); + return DateTime( + wall.year, + wall.month, + wall.day, + wall.hour, + wall.minute, + wall.second, + ); +} + +DateTime _addWallDuration(IcalTemporalValue value, Duration duration) { + final wall = value.localValue.add(duration); + if (value.kind == IcalTemporalKind.utcDateTime) return wall.toUtc(); + return DateTime( + wall.year, + wall.month, + wall.day, + wall.hour, + wall.minute, + wall.second, + ); +} + +String? _timeZone(IcalTemporalValue value) => switch (value.kind) { + IcalTemporalKind.utcDateTime => 'UTC', + IcalTemporalKind.tzidDateTime => value.timeZoneId, + IcalTemporalKind.date || IcalTemporalKind.floatingDateTime => null, +}; + +String? _transparency(String? value, BusyProvider provider) { + final normalized = value?.toUpperCase(); + if (provider == BusyProvider.microsoft) { + return normalized == 'TRANSPARENT' ? 'free' : 'busy'; + } + return switch (normalized) { + 'TRANSPARENT' => 'transparent', + 'OPAQUE' => 'opaque', + _ => null, + }; +} + +List _alarmMinutes(List alarms) { + final values = []; + for (final alarm in alarms) { + final action = alarm.firstProperty('ACTION')?.rawValue.toUpperCase(); + final trigger = alarm.firstProperty('TRIGGER'); + if ((action != 'DISPLAY' && action != 'AUDIO') || trigger == null) continue; + if (trigger.parameterValue('RELATED')?.toUpperCase() == 'END') continue; + try { + final duration = parseIcalDuration(trigger.rawValue.toUpperCase()); + if (duration == null || !duration.negative) continue; + final before = -duration.duration; + if (before.inSeconds <= 0 || 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. + } + } + return values; +} + +final class _PreparedImportDraft { + const _PreparedImportDraft({ + required this.draft, + required this.reason, + this.omittedFields = const {}, + }); + + const _PreparedImportDraft.unsupported(String reason) + : draft = null, + reason = reason, + omittedFields = const {}; + + final EventEditorDraft? draft; + final String? reason; + final Set omittedFields; +} diff --git a/lib/src/ical/ical_ingestion.dart b/lib/src/ical/ical_ingestion.dart new file mode 100644 index 00000000..ef8529db --- /dev/null +++ b/lib/src/ical/ical_ingestion.dart @@ -0,0 +1,253 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import '../dav/dav_errors.dart'; +import '../dav/ical/ical_document.dart'; +import '../dav/ical/ical_semantics.dart'; + +const icalIngestionDecodedBodyLimit = 16 * 1024 * 1024; + +enum IcalIngestionPolicy { fileImport, webCal } + +final class IcalIngestionException implements Exception { + const IcalIngestionException(this.code, this.safeMessage); + + final String code; + final String safeMessage; + + @override + String toString() => 'IcalIngestionException($code: $safeMessage)'; +} + +final class IcalRejectedEvent { + const IcalRejectedEvent({required this.uid, required this.code}); + + final String? uid; + final String code; +} + +final class IcalRecurrenceSet { + const IcalRecurrenceSet({required this.uid, required this.semantic}); + + final String uid; + final IcalSemanticDocument semantic; +} + +final class IcalIngestionResult { + const IcalIngestionResult({ + required this.document, + required this.recurrenceSets, + required this.rejectedEvents, + required this.unprojectedComponentTypes, + required this.serverRefreshIntervalSeconds, + }); + + final IcalDocument document; + final List recurrenceSets; + final List rejectedEvents; + final Set unprojectedComponentTypes; + final int? serverRefreshIntervalSeconds; +} + +abstract final class IcalIngestion { + static IcalIngestionResult parseBytes( + List input, { + required IcalIngestionPolicy policy, + }) { + if (input.length > icalIngestionDecodedBodyLimit) { + throw const IcalIngestionException( + 'IcalBodyTooLarge', + 'The calendar data exceeds the 16 MiB limit.', + ); + } + var bytes = Uint8List.fromList(input); + if (bytes.length >= 3 && + bytes[0] == 0xef && + bytes[1] == 0xbb && + bytes[2] == 0xbf) { + bytes = Uint8List.sublistView(bytes, 3); + } + final String source; + try { + source = utf8.decode(bytes, allowMalformed: false); + } on FormatException { + throw const IcalIngestionException( + 'IcalInvalidUtf8', + 'The calendar data is not valid UTF-8.', + ); + } + return parseString(source, policy: policy); + } + + static IcalIngestionResult parseString( + String source, { + required IcalIngestionPolicy policy, + }) { + if (utf8.encode(source).length > icalIngestionDecodedBodyLimit) { + throw const IcalIngestionException( + 'IcalBodyTooLarge', + 'The calendar data exceeds the 16 MiB limit.', + ); + } + final IcalDocument document; + try { + document = IcalDocument.parse(source); + } on DavException catch (error) { + throw IcalIngestionException(error.code, error.safeMessage); + } + _validateCalendarEnvelope(document); + + final timeZones = document.calendarComponents + .where((component) => component.name == 'VTIMEZONE') + .toList(growable: false); + final grouped = >{}; + final rejected = []; + final unknown = {}; + for (final component in document.calendarComponents) { + if (component.name != 'VEVENT') { + if (component.name != 'VTIMEZONE') unknown.add(component.name); + continue; + } + try { + final semantic = IcalSemanticComponent.fromDocumentComponent(component); + final uid = semantic.uid!; + grouped.putIfAbsent(uid, () => []).add(semantic); + } on DavException catch (error) { + if (policy == IcalIngestionPolicy.webCal) { + throw IcalIngestionException(error.code, error.safeMessage); + } + rejected.add( + IcalRejectedEvent( + uid: component.firstProperty('UID')?.rawValue.trim(), + code: error.code, + ), + ); + } + } + + final sets = []; + for (final entry in grouped.entries) { + final keys = {}; + var duplicate = false; + for (final component in entry.value) { + final key = component.recurrenceId?.recurrenceKey ?? ''; + if (!keys.add(key)) duplicate = true; + } + if (duplicate) { + if (policy == IcalIngestionPolicy.webCal) { + throw const IcalIngestionException( + 'IcalDuplicateLogicalComponent', + 'The calendar contains duplicate event recurrence components.', + ); + } + rejected.add( + IcalRejectedEvent( + uid: entry.key, + code: 'IcalDuplicateLogicalComponent', + ), + ); + continue; + } + try { + final recurrenceDocument = IcalDocument.create( + components: [ + for (final zone in timeZones) zone.deepCopy(), + for (final component in entry.value) + component.documentComponent.deepCopy(), + ], + ); + sets.add( + IcalRecurrenceSet( + uid: entry.key, + semantic: IcalSemanticDocument.fromDocument(recurrenceDocument), + ), + ); + } on DavException catch (error) { + if (policy == IcalIngestionPolicy.webCal) { + throw IcalIngestionException(error.code, error.safeMessage); + } + rejected.add(IcalRejectedEvent(uid: entry.key, code: error.code)); + } + } + return IcalIngestionResult( + document: document, + recurrenceSets: List.unmodifiable(sets), + rejectedEvents: List.unmodifiable(rejected), + unprojectedComponentTypes: Set.unmodifiable(unknown), + serverRefreshIntervalSeconds: _refreshIntervalSeconds(document), + ); + } +} + +void _validateCalendarEnvelope(IcalDocument document) { + final versions = document.root.propertiesNamed('VERSION').toList(); + if (versions.length != 1 || versions.single.rawValue.trim() != '2.0') { + throw const IcalIngestionException( + 'IcalInvalidVersion', + 'The calendar must contain exactly one VERSION:2.0 property.', + ); + } + final productIds = document.root.propertiesNamed('PRODID').toList(); + if (productIds.length != 1 || productIds.single.rawValue.trim().isEmpty) { + throw const IcalIngestionException( + 'IcalInvalidProductIdentifier', + 'The calendar must contain exactly one non-empty PRODID property.', + ); + } + if (document.calendarComponents.isEmpty) { + throw const IcalIngestionException( + 'IcalCalendarHasNoComponents', + 'The calendar contains no calendar components.', + ); + } +} + +int? _refreshIntervalSeconds(IcalDocument document) { + final property = document.root.firstProperty('REFRESH-INTERVAL'); + if (property == null || + property.parameterValue('VALUE')?.toUpperCase() != 'DURATION') { + return null; + } + try { + final duration = parseIcalDuration(property.rawValue.toUpperCase()); + if (duration == null || + duration.negative || + duration.duration <= Duration.zero) { + return null; + } + return duration.duration.inSeconds; + } on DavException { + return null; + } +} + +String sanitizedCanonicalSnapshot( + IcalDocument input, { + required Iterable secretValues, +}) { + final document = input.deepCopy(); + final secrets = secretValues.where((value) => value.isNotEmpty).toSet(); + void sanitize(IcalComponent component, {required bool topLevel}) { + component.children.removeWhere((node) { + if (node is! IcalProperty) return false; + if (topLevel && (node.name == 'SOURCE' || node.name == 'URL')) { + return true; + } + final values = [ + node.rawValue.trim(), + node.decodedTextValue.trim(), + for (final parameter in node.parameters) ...parameter.values, + ]; + return secrets.any( + (secret) => values.any((value) => value.contains(secret)), + ); + }); + for (final child in component.components) { + sanitize(child, topLevel: false); + } + component.structurallyDirty = true; + } + + sanitize(document.root, topLevel: true); + return document.serialize(canonicalizeUntouched: true); +} diff --git a/lib/src/l10n/app_locale.dart b/lib/src/l10n/app_locale.dart index 75747819..abebef92 100644 --- a/lib/src/l10n/app_locale.dart +++ b/lib/src/l10n/app_locale.dart @@ -1,5 +1,7 @@ import 'package:flutter/widgets.dart'; +import '../../l10n/generated/app_localizations.dart'; + /// A locale that can be selected explicitly in BusyMax. /// /// Language names are endonyms on purpose: users should be able to find their @@ -26,7 +28,7 @@ const busyMaxLocaleOptions = [ BusyMaxLocaleOption(locale: Locale('it'), endonym: 'Italiano'), BusyMaxLocaleOption(locale: Locale('ja'), endonym: '日本語'), BusyMaxLocaleOption(locale: Locale('ko'), endonym: '한국어'), - BusyMaxLocaleOption(locale: Locale('pt'), endonym: 'Português'), + BusyMaxLocaleOption(locale: Locale('pt', 'PT'), endonym: 'Português'), BusyMaxLocaleOption(locale: Locale('ru'), endonym: 'Русский'), BusyMaxLocaleOption(locale: Locale('vi'), endonym: 'Tiếng Việt'), BusyMaxLocaleOption( @@ -39,6 +41,18 @@ const busyMaxLocaleOptions = [ ), ]; +/// Locales exposed by BusyMax after removing generator-only fallbacks. +/// +/// Flutter's localization generator requires a base `pt` catalog before it +/// will generate `pt_PT`. BusyMax deliberately exposes only European +/// Portuguese so generic Portuguese and Brazilian Portuguese are not silently +/// resolved to the pt-PT catalog. +final busyMaxSupportedLocales = AppLocalizations.supportedLocales + .where( + (locale) => locale.languageCode != 'pt' || locale.countryCode == 'PT', + ) + .toList(growable: false); + const _traditionalChineseRegions = {'HK', 'MO', 'TW'}; const _simplifiedChineseRegions = {'CN', 'MY', 'SG'}; @@ -119,6 +133,9 @@ Locale resolveBusyMaxLocales( ]; final normalizedRequested = requestedLocales ?.map(_normalizeChineseLocale) + .where( + (locale) => locale.languageCode != 'pt' || locale.countryCode == 'PT', + ) .toList(growable: false); return basicLocaleListResolution(normalizedRequested, orderedSupported); } diff --git a/lib/src/l10n/locale_resolution.dart b/lib/src/l10n/locale_resolution.dart index 8daf0c79..710dc55f 100644 --- a/lib/src/l10n/locale_resolution.dart +++ b/lib/src/l10n/locale_resolution.dart @@ -1 +1,2 @@ -export 'app_locale.dart' show resolveBusyMaxLocale, resolveBusyMaxLocales; +export 'app_locale.dart' + show busyMaxSupportedLocales, resolveBusyMaxLocale, resolveBusyMaxLocales; diff --git a/lib/src/l10n/localized_formatters.dart b/lib/src/l10n/localized_formatters.dart index cc40a365..b2813326 100644 --- a/lib/src/l10n/localized_formatters.dart +++ b/lib/src/l10n/localized_formatters.dart @@ -1,11 +1,58 @@ import 'package:intl/intl.dart'; import '../schedule/schedule_range.dart'; +import '../schedule/schedule_view_mode.dart'; -// `intl` exposes CLDR's middle-of-sentence month names, but does not apply -// CLDR contextTransforms. These supported locales request titlecase-firstword -// for wide standalone month names used as UI headings. -const _titleCasedStandaloneMonthLanguages = {'es', 'fr', 'it', 'ru'}; +// `intl` exposes CLDR's grammatical contexts, but does not apply CLDR +// contextTransforms. Capitalization is therefore applied only to the +// standalone UI contexts whose locale conventions require it. +const _titleCasedDayLanguages = {'es', 'fr', 'it', 'pt', 'ru'}; +const _titleCasedMonthLanguages = {'es', 'fr', 'it', 'ru'}; + +String _languageCode(String locale) => + Intl.canonicalizedLocale(locale).split('_').first; + +String _applyHeadingCase( + String label, + String locale, + Set titleCasedLanguages, +) { + if (label.isEmpty || !titleCasedLanguages.contains(_languageCode(locale))) { + return label; + } + return toBeginningOfSentenceCase(label, locale); +} + +String _formatContextPattern(String pattern, String locale, DateTime value) { + // intl treats bare known patterns such as EEEE and MMMM as adaptable + // skeletons. The quoted marker makes this an explicit pattern so E/M use + // format-context names rather than standalone c/L names. + const marker = '|'; + final formatted = DateFormat("$pattern'$marker'", locale).format(value); + return formatted.substring(0, formatted.length - marker.length); +} + +/// Formats a complete date used as a standalone calendar-page heading. +String localizedDayHeading(String locale, DateTime day) { + final formatWeekday = _formatContextPattern('EEEE', locale, day); + final standaloneWeekday = localizedWeekdayLabel(locale, day); + var label = DateFormat.yMMMMEEEEd(locale).format(day); + final weekdayStart = label.indexOf(formatWeekday); + if (weekdayStart >= 0 && formatWeekday != standaloneWeekday) { + label = label.replaceRange( + weekdayStart, + weekdayStart + formatWeekday.length, + standaloneWeekday, + ); + } + return _applyHeadingCase(label, locale, _titleCasedDayLanguages); +} + +/// Formats a month and year used as a standalone calendar-page heading. +String localizedMonthYearHeading(String locale, DateTime month) { + final label = DateFormat.yMMMM(locale).format(month); + return _applyHeadingCase(label, locale, _titleCasedMonthLanguages); +} /// Formats a month used by itself as a calendar heading. /// @@ -13,13 +60,72 @@ const _titleCasedStandaloneMonthLanguages = {'es', 'fr', 'it', 'ru'}; /// separate because standalone grammar and standalone UI capitalization are /// distinct concepts in CLDR. String localizedMonthHeading(String locale, DateTime month) { - final label = DateFormat.LLLL(locale).format(month); - final language = Intl.canonicalizedLocale(locale).split('_').first; - if (label.isEmpty || - !_titleCasedStandaloneMonthLanguages.contains(language)) { - return label; - } - return '${label[0].toUpperCase()}${label.substring(1)}'; + return localizedMonthLabel(locale, month); +} + +/// Formats a standalone month label using the grammatical `L` form. +String localizedMonthLabel( + String locale, + DateTime month, { + bool abbreviated = false, +}) { + final label = (abbreviated ? DateFormat.LLL(locale) : DateFormat.LLLL(locale)) + .format(month); + return _applyHeadingCase(label, locale, _titleCasedMonthLanguages); +} + +/// Formats a standalone weekday label using the grammatical `c` form. +String localizedWeekdayLabel( + String locale, + DateTime day, { + bool abbreviated = false, +}) { + final label = _formatContextPattern( + abbreviated ? 'ccc' : 'cccc', + locale, + day, + ); + return _applyHeadingCase(label, locale, _titleCasedDayLanguages); +} + +/// Formats a weekday name inside a sentence using the grammatical `E` form. +String localizedInlineWeekday( + String locale, + DateTime day, { + bool abbreviated = false, +}) { + return _formatContextPattern(abbreviated ? 'EEE' : 'EEEE', locale, day); +} + +/// Formats a month name inside a sentence using the grammatical `M` form. +String localizedInlineMonth( + String locale, + DateTime month, { + bool abbreviated = false, +}) { + return _formatContextPattern(abbreviated ? 'MMM' : 'MMMM', locale, month); +} + +/// Formats a number using the active locale's decimal digits. +String localizedNumber(String locale, num value) { + return NumberFormat.decimalPattern(locale).format(value); +} + +/// Formats the title shared by the Flutter and native schedule headers. +String localizedScheduleHeading( + String locale, + ScheduleViewMode mode, + ScheduleRange range, + DateTime selectedDate, { + required String agendaLabel, +}) { + return switch (mode) { + ScheduleViewMode.day => localizedDayHeading(locale, selectedDate), + ScheduleViewMode.month => localizedMonthYearHeading(locale, selectedDate), + ScheduleViewMode.year => DateFormat.y(locale).format(selectedDate), + ScheduleViewMode.agenda => agendaLabel, + ScheduleViewMode.week => localizedScheduleRangeLabel(locale, range), + }; } /// Formats a closed-open schedule range without assuming a month/day/year diff --git a/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart b/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart index a347a1c1..b8d5e342 100644 --- a/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart +++ b/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart @@ -181,6 +181,19 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { ); } + @override + Future moveEvent({ + required String sourceCalendarId, + required String eventId, + required String destinationCalendarId, + CalendarGuestUpdatePolicy guestUpdatePolicy = + CalendarGuestUpdatePolicy.send, + }) { + throw UnsupportedError( + 'Microsoft Graph does not expose an event move operation.', + ); + } + @override Future respondToEvent({ required String calendarId, diff --git a/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart b/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart index 9f21b89e..8123ea98 100644 --- a/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart +++ b/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart @@ -23,6 +23,7 @@ CalendarSourceDto microsoftCalendarSourceFromJson(Map json) { ), colorId: json['color']?.toString(), accessRole: canEdit is bool && canEdit ? 'writer' : 'reader', + isRemovable: json['isRemovable'] as bool?, isDeleted: json['@removed'] != null, rawJson: json, ); @@ -88,7 +89,7 @@ CalendarEventDto microsoftCalendarEventFromJson( Map microsoftCalendarMutationToJson( CalendarMutation mutation, ) { - return _compact({'name': mutation.summary}); + return _compact({'name': mutation.summary, 'color': mutation.colorId}); } Map microsoftEventMutationToJson( @@ -120,6 +121,7 @@ Map microsoftEventMutationToJson( 'responseRequested': mutation.responseRequested, 'hideAttendees': mutation.hideAttendees, 'allowNewTimeProposals': mutation.allowNewTimeProposals, + 'transactionId': mutation.transactionId, if (mutation.reminders is Map) ..._reminderPatch(mutation.reminders!), if (_onlineMeetingProvider(mutation.conference) != null) ...{ 'isOnlineMeeting': true, diff --git a/lib/src/platform/busymax_tray_service.dart b/lib/src/platform/busymax_tray_service.dart index fe1efc6e..e0e84310 100644 --- a/lib/src/platform/busymax_tray_service.dart +++ b/lib/src/platform/busymax_tray_service.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:io'; import 'package:dbus/dbus.dart'; @@ -6,68 +5,174 @@ import 'package:logging/logging.dart'; import 'package:xdg_status_notifier_item/xdg_status_notifier_item.dart'; import '../core/logging/redacting_logger.dart'; -import 'linux_window_service.dart'; +import '../features/tray/domain/tray_presentation.dart'; const String busyMaxApplicationId = 'io.busystack.busymax'; const String busyMaxTrayMenuPath = '/StatusNotifierItem/menu'; -const int _busyMaxTrayRootMenuId = 0; -const int _busyMaxTrayOpenMenuId = 1; -const int _busyMaxTrayAgendaMenuId = 2; -const int _busyMaxTrayQuitMenuId = 3; -const int _busyMaxTrayOfflineMenuId = 4; - -class BusyMaxTrayLabels { - const BusyMaxTrayLabels({ - required this.openBusyMax, - required this.agenda, + +abstract final class BusyMaxTrayMenuIds { + static const root = 0; + static const showBusyMax = 1; + static const firstSeparator = 2; + static const newEvent = 3; + static const newTask = 4; + static const secondSeparator = 5; + static const today = 6; + static const firstEvent = 7; + static const secondEvent = 8; + static const thirdEvent = 9; + static const tasksDueToday = 10; + static const emptyToday = 11; + static const openTodayAgenda = 12; + static const thirdSeparator = 13; + static const syncNow = 14; + static const synchronizationStatus = 15; + static const fourthSeparator = 16; + static const settings = 17; + static const quitBusyMax = 18; + + static const orderedChildren = [ + showBusyMax, + firstSeparator, + newEvent, + newTask, + secondSeparator, + today, + firstEvent, + secondEvent, + thirdEvent, + tasksDueToday, + emptyToday, + openTodayAgenda, + thirdSeparator, + syncNow, + synchronizationStatus, + fourthSeparator, + settings, + quitBusyMax, + ]; +} + +final class BusyMaxTrayActions { + const BusyMaxTrayActions({ + required this.showBusyMax, + required this.newEvent, + required this.newTask, + required this.openEvent, + required this.openTasksDueToday, + required this.openTodayAgenda, + required this.synchronize, + required this.openSettings, required this.quitBusyMax, - required this.offline, - required this.offlineDescription, }); - final String openBusyMax; - final String agenda; - final String quitBusyMax; - final String offline; - final String offlineDescription; - - @override - bool operator ==(Object other) { - return other is BusyMaxTrayLabels && - other.openBusyMax == openBusyMax && - other.agenda == agenda && - other.quitBusyMax == quitBusyMax && - other.offline == offline && - other.offlineDescription == offlineDescription; - } + final Future Function() showBusyMax; + final Future Function() newEvent; + final Future Function() newTask; + final Future Function(BusyMaxTrayEventEntry event) openEvent; + final Future Function() openTasksDueToday; + final Future Function() openTodayAgenda; + final Future Function() synchronize; + final Future Function() openSettings; + final Future Function() quitBusyMax; +} - @override - int get hashCode => Object.hash( - openBusyMax, - agenda, - quitBusyMax, - offline, - offlineDescription, +final class BusyMaxTrayServiceConfiguration { + const BusyMaxTrayServiceConfiguration({ + required this.initialPresentation, + required this.loadPresentation, + required this.actions, + }); + + final BusyMaxTrayMenuPresentation initialPresentation; + final Future Function() loadPresentation; + final BusyMaxTrayActions actions; +} + +typedef BusyMaxTrayMenuUpdater = + Future Function( + DBusMenuItem menu, + BusyMaxTrayMenuPresentation presentation, + ); + +final class BusyMaxTrayMenuController { + BusyMaxTrayMenuController({ + required BusyMaxTrayMenuPresentation initialPresentation, + required Future Function() loadPresentation, + required BusyMaxTrayActions actions, + void Function(Object error)? onLoadFailure, + }) : _presentation = initialPresentation, + _loadPresentation = loadPresentation, + _actions = actions, + _onLoadFailure = onLoadFailure; + + final Future Function() _loadPresentation; + final BusyMaxTrayActions _actions; + final void Function(Object error)? _onLoadFailure; + BusyMaxTrayMenuPresentation _presentation; + BusyMaxTrayMenuUpdater? _updater; + Future? _activeRefresh; + + BusyMaxTrayMenuPresentation get presentation => _presentation; + + DBusMenuItem get menu => buildBusyMaxTrayMenu( + presentation: _presentation, + actions: _actions, + onAboutToShow: refresh, ); + + void attachUpdater(BusyMaxTrayMenuUpdater? updater) { + _updater = updater; + } + + Future refresh() { + final active = _activeRefresh; + if (active != null) return active; + final refresh = _loadAndPublish(); + _activeRefresh = refresh; + return refresh.whenComplete(() { + if (identical(_activeRefresh, refresh)) _activeRefresh = null; + }); + } + + Future _loadAndPublish() async { + try { + final next = await _loadPresentation(); + if (next == _presentation) return false; + final menu = buildBusyMaxTrayMenu( + presentation: next, + actions: _actions, + onAboutToShow: refresh, + ); + await _updater?.call(menu, next); + _presentation = next; + return true; + } on Object catch (error) { + _onLoadFailure?.call(error); + return false; + } + } } class BusyMaxTrayService { - BusyMaxTrayService({ - required LinuxWindowService windowService, - required BusyMaxTrayLabels labels, - required Future Function() onOpenAgenda, - }) : _windowService = windowService, - _labels = labels, - _onOpenAgenda = onOpenAgenda; - - final LinuxWindowService _windowService; - final Future Function() _onOpenAgenda; - BusyMaxTrayLabels _labels; + BusyMaxTrayService({required BusyMaxTrayServiceConfiguration configuration}) + : _actions = configuration.actions { + _menuControllerWithLogging = BusyMaxTrayMenuController( + initialPresentation: configuration.initialPresentation, + loadPresentation: configuration.loadPresentation, + actions: configuration.actions, + onLoadFailure: _logPresentationLoadFailure, + ); + } + + final BusyMaxTrayActions _actions; + late final BusyMaxTrayMenuController _menuControllerWithLogging; final RedactingLogger _logger = RedactingLogger(Logger('BusyMaxTrayService')); StatusNotifierItemClient? _client; bool _available = false; - bool _offline = false; + + BusyMaxTrayMenuController get _controller => _menuControllerWithLogging; bool get available => _available; @@ -77,77 +182,36 @@ class BusyMaxTrayService { _logger.fine('Tray initialization skipped: existing_client=true'); return; } - final iconName = _trayIconName(offline: _offline); - final initialLabels = _labels; - final initialOffline = _offline; - _logger.fine( - 'DBus menu creation starting: path=$busyMaxTrayMenuPath ' - 'root_id=$_busyMaxTrayRootMenuId ' - 'open_id=$_busyMaxTrayOpenMenuId agenda_id=$_busyMaxTrayAgendaMenuId ' - 'quit_id=$_busyMaxTrayQuitMenuId', - ); - final menu = buildBusyMaxTrayMenu( - labels: _labels, - offline: _offline, - onOpenBusyMax: _show, - onOpenAgenda: _showAgenda, - onQuit: _quit, - ); + await _controller.refresh(); + final exportedPresentation = _controller.presentation; + final menu = _controller.menu; + final iconName = _trayIconName(offline: exportedPresentation.offline); _logger.fine( 'DBus menu creation completed: path=$busyMaxTrayMenuPath ' 'items=${menu.children.length} ' 'ids=${menu.children.map((item) => item.id).join(',')}', ); - _logger.fine( - 'Tray initialization starting: snap=${_isRunningInSnap()} ' - 'icon=${_sanitizeIconForLog(iconName)} menu_items=${menu.children.length}', - ); final client = StatusNotifierItemClient( id: busyMaxApplicationId, - title: _trayTitle, + title: _trayTitle(exportedPresentation), iconName: iconName, - toolTipTitle: _trayTitle, - toolTipDescription: _trayToolTipDescription, + toolTipTitle: _trayTitle(exportedPresentation), + toolTipDescription: _trayToolTipDescription(exportedPresentation), itemIsMenu: true, menuPath: DBusObjectPath(busyMaxTrayMenuPath), menu: menu, diagnosticLog: (message) => _logger.fine(_sanitizeForLog('StatusNotifier diagnostic: $message')), - onContextMenu: (x, y) async { - _logger.fine( - 'Tray context menu callback fired: snap=${_isRunningInSnap()} ' - 'x=$x y=$y fallback=showWindow', - ); - await _showFromStatusNotifierActivation( - action: 'StatusNotifierItem context menu callback', - ); - }, - onActivate: (x, y) { - _logger.fine( - 'Tray activation callback fired: snap=${_isRunningInSnap()} ' - 'x=$x y=$y', - ); - return _showFromStatusNotifierActivation( - action: 'StatusNotifierItem activation callback', - ); - }, - onSecondaryActivate: (x, y) async { - _logger.fine( - 'Tray secondary activation callback fired: snap=${_isRunningInSnap()} ' - 'x=$x y=$y fallback=showWindow', - ); - await _showFromStatusNotifierActivation( - action: 'StatusNotifierItem secondary activation callback', - ); - }, + onActivate: (x, y) => handleActivate(), + onSecondaryActivate: (x, y) => handleSecondaryActivate(), ); try { await client.connect(); _client = client; _available = true; - if (_labels != initialLabels || _offline != initialOffline) { - await _updateClientPresentation(); - await _updateMenu(); + _controller.attachUpdater(_publishPresentation); + if (_controller.presentation != exportedPresentation) { + await _publishPresentation(_controller.menu, _controller.presentation); } final menuVerified = await _verifyMenuExported(); _logger.fine( @@ -157,115 +221,66 @@ class BusyMaxTrayService { 'menu_items=${menu.children.length}', ); } on Object catch (error) { + _controller.attachUpdater(null); _client = null; _available = false; _logger.warning( 'StatusNotifier registration failed: snap=${_isRunningInSnap()} ' - 'error=${_sanitizeForLog(error)}', + 'error_type=${error.runtimeType}', ); await client.close(); } } Future stop() async { + _controller.attachUpdater(null); final client = _client; _client = null; _available = false; await client?.close(); } - Future updateLabels(BusyMaxTrayLabels labels) async { - if (_labels == labels) { - return; - } - _labels = labels; - _logger.fine('Tray labels updating'); - await _updateClientPresentation(); - await _updateMenu(); - } - - Future updateOfflineState(bool offline) async { - if (_offline == offline) { - return; - } - _offline = offline; - _logger.fine('Tray connectivity presentation updating: offline=$offline'); - await _updateClientPresentation(); - await _updateMenu(); - } + Future refreshPresentation() => _controller.refresh(); - String get _trayTitle => - _offline ? 'BusyMax — ${_labels.offline}' : 'BusyMax'; + Future handleActivate() => _runLoggedTrayAction( + logger: _logger, + action: 'StatusNotifierItem activation callback', + callback: _actions.showBusyMax, + ); - String get _trayToolTipDescription => - _offline ? _labels.offlineDescription : ''; + Future handleSecondaryActivate() => _runLoggedTrayAction( + logger: _logger, + action: 'StatusNotifierItem secondary activation callback', + callback: _actions.openTodayAgenda, + ); - Future _updateClientPresentation() async { + Future _publishPresentation( + DBusMenuItem menu, + BusyMaxTrayMenuPresentation presentation, + ) async { final client = _client; - if (client == null) { - return; - } + if (client == null) return; + await client.updateMenu(menu); await client.updatePresentation( - title: _trayTitle, - iconName: _trayIconName(offline: _offline), - toolTipTitle: _trayTitle, - toolTipDescription: _trayToolTipDescription, + title: _trayTitle(presentation), + iconName: _trayIconName(offline: presentation.offline), + toolTipTitle: _trayTitle(presentation), + toolTipDescription: _trayToolTipDescription(presentation), ); } - Future _updateMenu() async { - final client = _client; - if (client == null) { - return; - } - final menu = buildBusyMaxTrayMenu( - labels: _labels, - offline: _offline, - onOpenBusyMax: _show, - onOpenAgenda: _showAgenda, - onQuit: _quit, - ); - await client.updateMenu(menu); - _logger.fine( - 'Tray menu update completed: menu_items=${menu.children.length}', - ); - } + String _trayTitle(BusyMaxTrayMenuPresentation presentation) => + presentation.offline + ? 'BusyMax — ${presentation.offlineTitle}' + : 'BusyMax'; - Future _showFromStatusNotifierActivation({required String action}) { - return _runLoggedTrayAction( - logger: _logger, - action: action, - callback: _show, - ); - } + String _trayToolTipDescription(BusyMaxTrayMenuPresentation presentation) => + presentation.offline ? presentation.offlineDescription : ''; - Future _show() { - _logger.fine( - 'Tray restore requested: action=showWindow target=main_window', + void _logPresentationLoadFailure(Object error) { + _logger.warning( + 'Tray presentation refresh failed: error_type=${error.runtimeType}', ); - return _windowService.showWindow(); - } - - Future _showAgenda() { - return _onOpenAgenda(); - } - - Future _quit() async { - _logger.fine('Tray quit requested: action=quitApp'); - unawaited(_stopAfterQuitRequest()); - await _windowService.quitApp(); - } - - Future _stopAfterQuitRequest() async { - try { - await stop(); - _logger.fine('Tray client close completed after quit request'); - } on Object catch (error) { - _logger.warning( - 'Tray client close failed after quit request: ' - 'error=${_sanitizeForLog(error)}', - ); - } } Future _verifyMenuExported() async { @@ -298,7 +313,7 @@ class BusyMaxTrayService { } on Object catch (error) { _logger.warning( 'Tray menu DBus verification failed: path=$busyMaxTrayMenuPath ' - 'error=${_sanitizeForLog(error)}', + 'error_type=${error.runtimeType}', ); return false; } finally { @@ -315,77 +330,149 @@ List _asMenuLayoutStruct(DBusValue value) { } DBusMenuItem buildBusyMaxTrayMenu({ - required BusyMaxTrayLabels labels, - bool offline = false, - required Future Function() onOpenBusyMax, - required Future Function() onOpenAgenda, - required Future Function() onQuit, + required BusyMaxTrayMenuPresentation presentation, + required BusyMaxTrayActions actions, + Future Function()? onAboutToShow, }) { + final eventRows = presentation.eventRows; return DBusMenuItem( - id: _busyMaxTrayRootMenuId, + id: BusyMaxTrayMenuIds.root, enabled: true, visible: true, + onAboutToShow: onAboutToShow, children: [ - if (offline) - DBusMenuItem( - id: _busyMaxTrayOfflineMenuId, - enabled: false, - visible: true, - label: '${labels.offline} — ${labels.offlineDescription}', - ), + _actionItem( + id: BusyMaxTrayMenuIds.showBusyMax, + label: presentation.showBusyMaxLabel, + callback: actions.showBusyMax, + ), + DBusMenuItem.separator(id: BusyMaxTrayMenuIds.firstSeparator), + _actionItem( + id: BusyMaxTrayMenuIds.newEvent, + label: presentation.newEventLabel, + enabled: presentation.canCreateEvent, + callback: actions.newEvent, + ), + _actionItem( + id: BusyMaxTrayMenuIds.newTask, + label: presentation.newTaskLabel, + enabled: presentation.canCreateTask, + callback: actions.newTask, + ), + DBusMenuItem.separator(id: BusyMaxTrayMenuIds.secondSeparator), DBusMenuItem( - id: _busyMaxTrayOpenMenuId, - enabled: true, + id: BusyMaxTrayMenuIds.today, + enabled: false, visible: true, - label: labels.openBusyMax, - onClicked: () => _runLoggedTrayAction( - logger: RedactingLogger(Logger('BusyMaxTrayService')), - action: 'Tray menu "Open BusyMax" callback', - callback: onOpenBusyMax, + label: presentation.todayLabel, + ), + for (var index = 0; index < 3; index += 1) + _eventSlot( + id: BusyMaxTrayMenuIds.firstEvent + index, + row: index < eventRows.length ? eventRows[index] : null, + actions: actions, ), + _actionItem( + id: BusyMaxTrayMenuIds.tasksDueToday, + label: presentation.taskSummaryLabel ?? '', + visible: presentation.taskSummaryLabel != null, + enabled: presentation.taskSummaryLabel != null, + callback: actions.openTasksDueToday, ), DBusMenuItem( - id: _busyMaxTrayAgendaMenuId, - enabled: true, - visible: true, - label: labels.agenda, - onClicked: () => _runLoggedTrayAction( - logger: RedactingLogger(Logger('BusyMaxTrayService')), - action: 'Tray menu "Agenda" callback', - callback: onOpenAgenda, - ), + id: BusyMaxTrayMenuIds.emptyToday, + enabled: false, + visible: presentation.showEmptyState, + label: presentation.emptyStateLabel, + ), + _actionItem( + id: BusyMaxTrayMenuIds.openTodayAgenda, + label: presentation.openTodayAgendaLabel, + callback: actions.openTodayAgenda, + ), + DBusMenuItem.separator(id: BusyMaxTrayMenuIds.thirdSeparator), + _actionItem( + id: BusyMaxTrayMenuIds.syncNow, + label: presentation.syncNowLabel, + enabled: presentation.canSynchronize, + callback: actions.synchronize, ), DBusMenuItem( - id: _busyMaxTrayQuitMenuId, - enabled: true, + id: BusyMaxTrayMenuIds.synchronizationStatus, + enabled: false, visible: true, - label: labels.quitBusyMax, - onClicked: () => _runLoggedTrayAction( - logger: RedactingLogger(Logger('BusyMaxTrayService')), - action: 'Tray menu "Quit" callback', - callback: onQuit, - ), + label: presentation.synchronizationStatusLabel, + ), + DBusMenuItem.separator(id: BusyMaxTrayMenuIds.fourthSeparator), + _actionItem( + id: BusyMaxTrayMenuIds.settings, + label: presentation.settingsLabel, + callback: actions.openSettings, + ), + _actionItem( + id: BusyMaxTrayMenuIds.quitBusyMax, + label: presentation.quitBusyMaxLabel, + callback: actions.quitBusyMax, ), ], ); } +DBusMenuItem _eventSlot({ + required int id, + required BusyMaxTrayEventRowPresentation? row, + required BusyMaxTrayActions actions, +}) { + return DBusMenuItem( + id: id, + enabled: row != null, + visible: row != null, + label: row?.label ?? '', + onClicked: row == null + ? null + : () => _runLoggedTrayAction( + logger: RedactingLogger(Logger('BusyMaxTrayService')), + action: 'Tray event slot callback', + callback: () => actions.openEvent(row.event), + ), + ); +} + +DBusMenuItem _actionItem({ + required int id, + required String label, + required Future Function() callback, + bool enabled = true, + bool visible = true, +}) { + return DBusMenuItem( + id: id, + enabled: enabled, + visible: visible, + label: label, + onClicked: enabled && visible + ? () => _runLoggedTrayAction( + logger: RedactingLogger(Logger('BusyMaxTrayService')), + action: 'Tray menu callback', + callback: callback, + ) + : null, + ); +} + Future _runLoggedTrayAction({ required RedactingLogger logger, required String action, required Future Function() callback, }) async { - logger.fine( - 'Tray callback fired: action="$action" snap=${_isRunningInSnap()}', - ); + logger.fine('Tray callback fired: action="$action"'); try { await callback(); logger.fine('Tray callback completed: action="$action"'); } on Object catch (error) { logger.warning( - 'Tray callback failed: action="$action" error=${_sanitizeForLog(error)}', + 'Tray callback failed: action="$action" error_type=${error.runtimeType}', ); - rethrow; } } @@ -396,16 +483,12 @@ String _trayIconName({required bool offline}) { '${executableDir.path}/data/flutter_assets/assets/branding/' 'busymax-logo-offline.svg', ); - if (bundledOfflineLogo.existsSync()) { - return bundledOfflineLogo.path; - } + if (bundledOfflineLogo.existsSync()) return bundledOfflineLogo.path; } final bundledLogo = File( '${executableDir.path}/data/flutter_assets/assets/branding/busymax-logo.svg', ); - if (bundledLogo.existsSync()) { - return bundledLogo.path; - } + if (bundledLogo.existsSync()) return bundledLogo.path; return busyMaxApplicationId; } @@ -414,11 +497,3 @@ bool _isRunningInSnap() => Platform.environment['SNAP']?.isNotEmpty ?? false; String _sanitizeForLog(Object? value) { return redactForLog(value).replaceAll(RegExp(r'\s+'), ' ').trim(); } - -String _sanitizeIconForLog(String iconName) { - if (iconName == busyMaxApplicationId) { - return busyMaxApplicationId; - } - final basename = iconName.split(Platform.pathSeparator).last; - return basename.isEmpty ? '' : basename; -} diff --git a/lib/src/platform/common/desktop_services.dart b/lib/src/platform/common/desktop_services.dart new file mode 100644 index 00000000..280d7a2a --- /dev/null +++ b/lib/src/platform/common/desktop_services.dart @@ -0,0 +1,296 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +const busyMaxStartMinimizedArgument = '--start-minimized'; + +abstract interface class DesktopWindowService { + Future setHideOnClose(bool enabled); + + Future hideWindow(); + + Future showWindow(); + + Future quitApp(); + + Future isWindowVisible(); +} + +abstract interface class DesktopTrayService { + bool get isAvailable; + + Future start(); + + Future refresh(); + + Future stop(); +} + +enum DesktopAutostartState { + enabled, + disabled, + disabledByUser, + disabledByPolicy, + unavailable, +} + +abstract interface class DesktopAutostartService { + Future state(); + + Future setEnabled(bool enabled); +} + +enum DesktopActivationKind { + normalLaunch, + startMinimized, + icsFile, + webCal, + notification, +} + +@immutable +final class DesktopActivation { + const DesktopActivation({ + required this.kind, + this.value, + this.action, + this.payload, + }); + + static const maximumEncodedBytes = 16 * 1024; + + final DesktopActivationKind kind; + final String? value; + final String? action; + final Map? payload; + + bool get requiresVisibleWindow => switch (kind) { + DesktopActivationKind.startMinimized => false, + DesktopActivationKind.notification => + action == 'default' || action == 'open', + _ => true, + }; + + Map toJson() => { + 'version': 1, + 'kind': kind.name, + if (value != null) 'value': value, + if (action != null) 'action': action, + if (payload != null) 'payload': payload, + }; + + String encode() => jsonEncode(toJson()); + + static DesktopActivation? tryDecode(String encoded) { + if (utf8.encode(encoded).length > maximumEncodedBytes) return null; + try { + final decoded = jsonDecode(encoded); + if (decoded is! Map || decoded['version'] != 1) { + return null; + } + final kindName = decoded['kind']; + if (kindName is! String) return null; + final kind = DesktopActivationKind.values + .where((candidate) => candidate.name == kindName) + .firstOrNull; + if (kind == null) return null; + final allowedKeys = switch (kind) { + DesktopActivationKind.normalLaunch || + DesktopActivationKind.startMinimized => const {'version', 'kind'}, + DesktopActivationKind.icsFile || + DesktopActivationKind.webCal => const {'version', 'kind', 'value'}, + DesktopActivationKind.notification => const { + 'version', + 'kind', + 'action', + 'payload', + }, + }; + if (decoded.keys.any((key) => !allowedKeys.contains(key))) return null; + final value = decoded['value']; + final action = decoded['action']; + final rawPayload = decoded['payload']; + if (value != null && value is! String || + action != null && action is! String || + rawPayload != null && rawPayload is! Map) { + return null; + } + Map? payload; + if (rawPayload != null) { + payload = {}; + for (final MapEntry(:key, :value) + in (rawPayload as Map).entries) { + if (key is! String || value is! String) return null; + if (key.length > 64 || value.length > 2048) return null; + payload[key] = value; + } + } + final activation = DesktopActivation( + kind: kind, + value: value as String?, + action: action as String?, + payload: payload, + ); + return activation.isValid ? activation : null; + } on FormatException { + return null; + } + } + + bool get isValid { + switch (kind) { + case DesktopActivationKind.normalLaunch: + case DesktopActivationKind.startMinimized: + return value == null && action == null && payload == null; + case DesktopActivationKind.icsFile: + final path = value; + return path != null && + path.isNotEmpty && + path.length <= 32767 && + path.toLowerCase().endsWith('.ics') && + !path.contains('\u0000'); + case DesktopActivationKind.webCal: + final uri = Uri.tryParse(value ?? ''); + return uri != null && + uri.scheme.toLowerCase() == 'webcal' && + uri.host.isNotEmpty && + uri.userInfo.isEmpty && + !uri.hasFragment; + case DesktopActivationKind.notification: + const permittedActions = {'default', 'open', 'snooze', 'dismiss'}; + const permittedPayloadKeys = { + 'notificationScheduleId', + 'itemKind', + 'accountId', + 'sourceId', + 'itemId', + }; + return permittedActions.contains(action) && + payload != null && + payload!.containsKey('notificationScheduleId') && + payload!.entries.every( + (entry) => + permittedPayloadKeys.contains(entry.key) && + entry.value.isNotEmpty && + entry.value.length <= 2048, + ); + } + } +} + +abstract interface class DesktopActivationService { + Stream get activations; + + Future initialize(); + + Future dispose(); +} + +final class NoOpDesktopActivationService implements DesktopActivationService { + const NoOpDesktopActivationService(); + + @override + Stream get activations => const Stream.empty(); + + @override + Future dispose() async {} + + @override + Future initialize() async {} +} + +enum DesktopNavigationDestination { schedule, tasks, settings, signIn } + +@immutable +final class DesktopNavigationRequest { + const DesktopNavigationRequest(this.destination); + + final DesktopNavigationDestination destination; +} + +abstract interface class DesktopNavigationService { + Stream get requests; + + void open(DesktopNavigationDestination destination); +} + +final class QueuedDesktopNavigationService implements DesktopNavigationService { + QueuedDesktopNavigationService(); + + final _requests = StreamController.broadcast(); + + @override + Stream get requests => _requests.stream; + + @override + void open(DesktopNavigationDestination destination) { + _requests.add(DesktopNavigationRequest(destination)); + } + + Future dispose() => _requests.close(); +} + +abstract interface class SystemAppearanceSource { + Color? get accentColor; + + Brightness get brightness; + + bool get highContrast; + + Stream get changes; +} + +abstract interface class LocalTimeZoneSource { + /// A non-sensitive technical diagnostic when the source had to fall back. + String? get diagnostic; + + Future currentIanaTimeZone(); +} + +final class FixedLocalTimeZoneSource implements LocalTimeZoneSource { + const FixedLocalTimeZoneSource(this.timeZone); + + final String timeZone; + + @override + String? get diagnostic => null; + + @override + Future currentIanaTimeZone() async => timeZone; +} + +final class UnavailableDesktopAutostartService + implements DesktopAutostartService { + const UnavailableDesktopAutostartService(); + + @override + Future state() async { + return DesktopAutostartState.unavailable; + } + + @override + Future setEnabled(bool enabled) { + throw UnsupportedError('Launch at login is unavailable in this build.'); + } +} + +final class NoOpDesktopWindowService implements DesktopWindowService { + const NoOpDesktopWindowService(); + + @override + Future hideWindow() async {} + + @override + Future isWindowVisible() async => true; + + @override + Future quitApp() async {} + + @override + Future setHideOnClose(bool enabled) async {} + + @override + Future showWindow() async {} +} diff --git a/lib/src/platform/external_calendar_open_service.dart b/lib/src/platform/external_calendar_open_service.dart new file mode 100644 index 00000000..a2f7ea0d --- /dev/null +++ b/lib/src/platform/external_calendar_open_service.dart @@ -0,0 +1,152 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +import 'common/desktop_services.dart'; + +const externalCalendarOpenChannelName = + 'io.busystack.busymax/external_calendar_open'; + +enum ExternalCalendarOpenKind { webCal, icsFile } + +final class ExternalCalendarOpenRequest { + const ExternalCalendarOpenRequest({required this.kind, required this.value}); + + final ExternalCalendarOpenKind kind; + final String value; +} + +/// Validates and queues native desktop activations before exposing them to the +/// application. Missing native support is treated as an ordinary launch, so an +/// unpackaged or test build never fails merely because the channel is absent. +final class ExternalCalendarOpenService implements DesktopActivationService { + ExternalCalendarOpenService({ + MethodChannel channel = const MethodChannel( + externalCalendarOpenChannelName, + ), + }) : _channel = channel; + + final MethodChannel _channel; + late final StreamController _activations = + StreamController.broadcast(onListen: _flushPending); + final _calendarRequests = + StreamController.broadcast(); + bool _initialized = false; + final List _pending = []; + + @override + Stream get activations => _activations.stream; + + /// Compatibility stream retained for the Linux presentation while the + /// common activation model is used by both application roots. + Stream get requests => _calendarRequests.stream; + + @override + Future initialize() async { + if (_initialized) return; + _initialized = true; + _channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'openItem': + _acceptLegacyOpenItem(call.arguments); + case 'activation': + _acceptEncoded(call.arguments); + } + }); + try { + final pending = await _channel.invokeListMethod('ready'); + for (final value in pending ?? const []) { + _acceptEncoded(value); + } + } on MissingPluginException { + // Tests and unsupported/unpackaged runners have no activation bridge. + } on PlatformException { + // Invalid native activation state must not prevent ordinary startup. + } + } + + void _acceptLegacyOpenItem(Object? raw) { + if (raw is! Map) return; + final arguments = Map.from(raw); + final kind = arguments['kind']; + final value = arguments['value']; + if (value is! String) return; + final activation = switch (kind) { + 'webcal' => DesktopActivation( + kind: DesktopActivationKind.webCal, + value: value, + ), + 'ics' => DesktopActivation( + kind: DesktopActivationKind.icsFile, + value: value, + ), + _ => null, + }; + if (activation?.isValid == true) _emit(activation!); + } + + void _acceptEncoded(Object? raw) { + final DesktopActivation? activation; + if (raw is String) { + activation = DesktopActivation.tryDecode(raw); + } else if (raw is Map) { + try { + activation = DesktopActivation.tryDecode( + jsonEncode(Map.from(raw)), + ); + } on JsonUnsupportedObjectError { + return; + } + } else { + activation = null; + } + if (activation != null) _emit(activation); + } + + void _emit(DesktopActivation activation) { + if (_activations.hasListener) { + _activations.add(activation); + } else { + _pending.add(activation); + } + switch (activation.kind) { + case DesktopActivationKind.webCal: + _calendarRequests.add( + ExternalCalendarOpenRequest( + kind: ExternalCalendarOpenKind.webCal, + value: activation.value!, + ), + ); + case DesktopActivationKind.icsFile: + _calendarRequests.add( + ExternalCalendarOpenRequest( + kind: ExternalCalendarOpenKind.icsFile, + value: activation.value!, + ), + ); + case DesktopActivationKind.normalLaunch: + case DesktopActivationKind.startMinimized: + case DesktopActivationKind.notification: + break; + } + } + + void _flushPending() { + if (_pending.isEmpty) return; + final queued = List.of(_pending); + _pending.clear(); + scheduleMicrotask(() { + for (final activation in queued) { + if (!_activations.isClosed) _activations.add(activation); + } + }); + } + + @override + Future dispose() async { + _channel.setMethodCallHandler(null); + await _activations.close(); + await _calendarRequests.close(); + } +} diff --git a/lib/src/platform/linux/linux_network_connectivity_monitor.dart b/lib/src/platform/linux/linux_network_connectivity_monitor.dart new file mode 100644 index 00000000..ddf02bc0 --- /dev/null +++ b/lib/src/platform/linux/linux_network_connectivity_monitor.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import '../../features/connectivity/network_connectivity_service.dart'; + +typedef ObservedConnectivityMonitorFactory = + NetworkConnectivityMonitor Function(); + +/// Avoids NetworkManager's system D-Bus API inside a strictly confined Snap. +NetworkConnectivityMonitor createLinuxNetworkConnectivityMonitor({ + Map? environment, + ObservedConnectivityMonitorFactory? observedMonitorFactory, +}) { + final runtimeEnvironment = environment ?? Platform.environment; + final snapPath = runtimeEnvironment['SNAP']?.trim(); + if (snapPath != null && snapPath.isNotEmpty) { + return NetworkConnectivityMonitor.withoutPlatformObservation(); + } + return (observedMonitorFactory ?? NetworkConnectivityMonitor.new)(); +} diff --git a/lib/src/platform/linux/linux_notification_backend.dart b/lib/src/platform/linux/linux_notification_backend.dart new file mode 100644 index 00000000..ac82398c --- /dev/null +++ b/lib/src/platform/linux/linux_notification_backend.dart @@ -0,0 +1,64 @@ +import 'dart:async'; + +import 'package:desktop_notifications/desktop_notifications.dart'; + +import '../../features/notifications/desktop_notification_backend.dart'; + +final class FreedesktopNotificationBackend + implements DesktopNotificationBackend { + FreedesktopNotificationBackend({NotificationsClient? client}) + : _client = client ?? NotificationsClient(); + + final NotificationsClient _client; + final Map _notifications = {}; + + @override + Future notify( + BusyMaxNotificationRequest request, { + DesktopNotificationActionHandler? onAction, + }) async { + final previous = _notifications[request.stableId]; + final notification = await _client.notify( + request.title, + appName: 'BusyMax', + appIcon: 'io.busystack.busymax', + body: request.body, + replacesId: previous?.id ?? 0, + hints: [ + NotificationHint.category(_category(request.category)), + if (request.transient) NotificationHint.transient(), + ], + actions: [ + for (final action in request.actions) + NotificationAction(action.id, action.label), + ], + ); + _notifications[request.stableId] = notification; + if (onAction != null) { + unawaited( + notification.action + .then((action) => onAction(action, request.payload)) + .catchError((Object _) => Future.value()), + ); + } + } + + NotificationCategory _category(BusyMaxNotificationCategory category) { + return switch (category) { + BusyMaxNotificationCategory.networkError => + NotificationCategory.networkError(), + BusyMaxNotificationCategory.conflict => + NotificationCategory.deviceError(), + _ => NotificationCategory.device(), + }; + } + + @override + Future cancel(String stableId) async { + final notification = _notifications.remove(stableId); + await notification?.close(); + } + + @override + Future close() => _client.close(); +} diff --git a/lib/src/platform/linux/linux_secret_storage_presentation.dart b/lib/src/platform/linux/linux_secret_storage_presentation.dart new file mode 100644 index 00000000..7a9809db --- /dev/null +++ b/lib/src/platform/linux/linux_secret_storage_presentation.dart @@ -0,0 +1,9 @@ +import '../../core/secrets/secret_store.dart'; + +const linuxSecretStoragePresentation = SecretStoragePresentation( + backendDomain: 'busymax.secure_storage.linux', + runtimeBackend: 'linux-keyring', + unavailableMessage: + 'Secure credential storage is temporarily unavailable. Unlock your ' + 'system keyring and try again.', +); diff --git a/lib/src/platform/linux_autostart_service.dart b/lib/src/platform/linux_autostart_service.dart index 7ac432f7..23f59f25 100644 --- a/lib/src/platform/linux_autostart_service.dart +++ b/lib/src/platform/linux_autostart_service.dart @@ -2,10 +2,11 @@ import 'dart:io'; import 'package:path/path.dart' as path; +import 'common/desktop_services.dart'; + const busyMaxAutostartFileName = 'io.busystack.busymax-autostart.desktop'; -const busyMaxStartMinimizedArgument = '--start-minimized'; -final class LinuxAutostartService { +final class LinuxAutostartService implements DesktopAutostartService { LinuxAutostartService({ Map? environment, String? executable, @@ -23,6 +24,14 @@ final class LinuxAutostartService { return _autostartFile().exists(); } + @override + Future state() async { + return await isEnabled() + ? DesktopAutostartState.enabled + : DesktopAutostartState.disabled; + } + + @override Future setEnabled(bool enabled) async { if (!_isLinux) { throw UnsupportedError('Launch at login is supported only on Linux.'); diff --git a/lib/src/platform/linux_window_service.dart b/lib/src/platform/linux_window_service.dart index ee81b532..1615c51e 100644 --- a/lib/src/platform/linux_window_service.dart +++ b/lib/src/platform/linux_window_service.dart @@ -1,28 +1,35 @@ import 'package:flutter/services.dart'; -class LinuxWindowService { +import 'common/desktop_services.dart'; + +class LinuxWindowService implements DesktopWindowService { const LinuxWindowService({ MethodChannel channel = const MethodChannel('io.busystack.busymax/window'), }) : _channel = channel; final MethodChannel _channel; + @override Future setHideOnClose(bool enabled) { return _channel.invokeMethod('setHideOnClose', enabled); } + @override Future hideWindow() { return _channel.invokeMethod('hideWindow'); } + @override Future showWindow() { return _channel.invokeMethod('showWindow'); } + @override Future quitApp() { return _channel.invokeMethod('quitApp'); } + @override Future isWindowVisible() async { return await _channel.invokeMethod('isWindowVisible') ?? false; } diff --git a/lib/src/platform/native_menu_service.dart b/lib/src/platform/native_menu_service.dart index e365e1be..537ab525 100644 --- a/lib/src/platform/native_menu_service.dart +++ b/lib/src/platform/native_menu_service.dart @@ -17,7 +17,9 @@ final class NativeMenuSession { final int id; } -/// One command exposed by a host-toolkit menu. +enum NativeMenuEntryRole { command, radio, toggle } + +/// One entry exposed by a host-toolkit menu. /// /// Entries intentionally contain presentation state only. The selected index /// is mapped back to the caller's domain value after the native menu closes. @@ -27,6 +29,7 @@ final class NativeMenuEntry { required this.label, this.iconName, this.enabled = true, + this.role = NativeMenuEntryRole.command, this.selected = false, this.shortcut, }); @@ -34,6 +37,7 @@ final class NativeMenuEntry { final String label; final String? iconName; final bool enabled; + final NativeMenuEntryRole role; final bool selected; final String? shortcut; @@ -42,6 +46,7 @@ final class NativeMenuEntry { 'label': label, if (iconName != null && iconName!.isNotEmpty) 'icon': iconName!, 'enabled': enabled, + 'role': role.name, 'selected': selected, if (shortcut != null && shortcut!.isNotEmpty) 'shortcut': shortcut!, }; diff --git a/lib/src/platform/windows/windows_activation_service.dart b/lib/src/platform/windows/windows_activation_service.dart new file mode 100644 index 00000000..0097dabb --- /dev/null +++ b/lib/src/platform/windows/windows_activation_service.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import '../common/desktop_services.dart'; +import 'windows_window_service.dart'; + +Future forwardWindowsActivationToPrimary( + DesktopActivation activation, { + MethodChannel channel = const MethodChannel(windowsDesktopChannelName), +}) async { + if (!activation.isValid) return false; + try { + return await channel.invokeMethod( + 'forwardActivation', + activation.encode(), + ) ?? + false; + } on MissingPluginException { + return false; + } +} + +final class WindowsActivationService implements DesktopActivationService { + WindowsActivationService({ + MethodChannel channel = const MethodChannel(windowsDesktopChannelName), + }) : _channel = channel { + _controller = StreamController.broadcast( + onListen: _flushPending, + ); + } + + final MethodChannel _channel; + late final StreamController _controller; + final _pending = []; + bool _initialized = false; + + @override + Stream get activations => _controller.stream; + + @override + Future initialize() async { + if (_initialized) return; + _initialized = true; + _channel.setMethodCallHandler((call) async { + if (call.method == 'activation') { + acceptEncoded(call.arguments); + } + }); + try { + final initial = + await _channel.invokeListMethod('takeInitialActivations') ?? + const []; + for (final encoded in initial) { + acceptEncoded(encoded); + } + await _channel.invokeMethod('activationReady'); + } on MissingPluginException { + // Unpackaged tests may not load the native runner bridge. + } + _flushPending(); + } + + void acceptEncoded(Object? encoded) { + if (encoded is! String) return; + final activation = DesktopActivation.tryDecode(encoded); + if (activation != null) accept(activation); + } + + void accept(DesktopActivation activation) { + if (!activation.isValid) return; + if (!_initialized || !_controller.hasListener) { + _pending.add(activation); + } else { + _controller.add(activation); + } + } + + void _flushPending() { + if (!_initialized || !_controller.hasListener || _pending.isEmpty) return; + for (final activation in _pending) { + _controller.add(activation); + } + _pending.clear(); + } + + @override + Future dispose() async { + _channel.setMethodCallHandler(null); + await _controller.close(); + } +} diff --git a/lib/src/platform/windows/windows_autostart_service.dart b/lib/src/platform/windows/windows_autostart_service.dart new file mode 100644 index 00000000..d59edcd0 --- /dev/null +++ b/lib/src/platform/windows/windows_autostart_service.dart @@ -0,0 +1,40 @@ +import 'package:flutter/services.dart'; + +import '../common/desktop_services.dart'; +import 'windows_window_service.dart'; + +final class WindowsAutostartService implements DesktopAutostartService { + const WindowsAutostartService({ + MethodChannel channel = const MethodChannel(windowsDesktopChannelName), + }) : _channel = channel; + + final MethodChannel _channel; + + @override + Future state() async { + try { + final value = await _channel.invokeMethod('getStartupTaskState'); + return switch (value) { + 'enabled' => DesktopAutostartState.enabled, + 'disabled' => DesktopAutostartState.disabled, + 'disabledByUser' => DesktopAutostartState.disabledByUser, + 'disabledByPolicy' => DesktopAutostartState.disabledByPolicy, + _ => DesktopAutostartState.unavailable, + }; + } on MissingPluginException { + return DesktopAutostartState.unavailable; + } on PlatformException { + return DesktopAutostartState.unavailable; + } + } + + @override + Future setEnabled(bool enabled) async { + final current = await state(); + if (current == DesktopAutostartState.unavailable || + current == DesktopAutostartState.disabledByPolicy) { + throw UnsupportedError('Windows StartupTask is unavailable.'); + } + await _channel.invokeMethod('setStartupTaskEnabled', enabled); + } +} diff --git a/lib/src/platform/windows/windows_notification_backend.dart b/lib/src/platform/windows/windows_notification_backend.dart new file mode 100644 index 00000000..437ff806 --- /dev/null +++ b/lib/src/platform/windows/windows_notification_backend.dart @@ -0,0 +1,310 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +import '../../features/notifications/desktop_notification_backend.dart'; +import '../common/desktop_services.dart'; +import 'windows_notification_id_store.dart'; +import 'windows_window_service.dart'; + +const busyMaxToastActivatorClsid = '7B854A6D-8B2A-45A5-B998-1F51EC5A81D7'; +const busyMaxNotificationActivationServerArgument = + '----AppNotificationActivationServer'; +const busyMaxNotificationForwarderArgument = '--busymax-notification-forwarder'; + +final class WindowsNotificationBackend implements DesktopNotificationBackend { + WindowsNotificationBackend._({ + required WindowsNotificationPluginAdapter plugin, + required void Function(DesktopActivation activation) onActivation, + required WindowsNotificationIdStore? idStore, + }) : _plugin = plugin, + _onActivation = onActivation, + _idStore = idStore; + + static Future hasPackageIdentity({ + MethodChannel channel = const MethodChannel(windowsDesktopChannelName), + }) async { + try { + return await channel.invokeMethod('hasPackageIdentity') ?? false; + } on MissingPluginException { + return false; + } + } + + static Future create({ + required String appUserModelId, + required void Function(DesktopActivation activation) onActivation, + WindowsNotificationPluginAdapter? plugin, + WindowsNotificationIdStore? idStore, + bool activationForwarderOnly = false, + }) async { + if (appUserModelId.trim().isEmpty) { + throw ArgumentError.value( + appUserModelId, + 'appUserModelId', + 'A stable AUMID is required for Windows notifications.', + ); + } + WindowsNotificationIdStore? notificationIds; + if (!activationForwarderOnly) { + notificationIds = + idStore ?? await WindowsNotificationIdStore.openDefault(); + await notificationIds.initialize(); + } + final notifications = + plugin ?? + FlutterWindowsNotificationPluginAdapter( + FlutterLocalNotificationsPlugin(), + ); + late final WindowsNotificationBackend backend; + backend = WindowsNotificationBackend._( + plugin: notifications, + onActivation: onActivation, + idStore: notificationIds, + ); + try { + final initialized = await notifications.initialize( + settings: InitializationSettings( + windows: WindowsInitializationSettings( + appName: 'BusyMax', + appUserModelId: appUserModelId, + guid: busyMaxToastActivatorClsid, + ), + ), + onDidReceiveNotificationResponse: backend._handleResponse, + ); + if (initialized != true) { + throw const WindowsNotificationInitializationException( + 'toast-registration-failed', + ); + } + final launch = await notifications.getNotificationAppLaunchDetails(); + if (launch?.didNotificationLaunchApp ?? false) { + final response = launch?.notificationResponse; + if (response == null) { + throw const WindowsNotificationInitializationException( + 'cold-activation-invalid', + ); + } + backend._handleResponse(response); + } + } on WindowsNotificationInitializationException { + rethrow; + } on Object { + throw const WindowsNotificationInitializationException( + 'initialization-failed', + ); + } + return backend; + } + + final WindowsNotificationPluginAdapter _plugin; + final void Function(DesktopActivation activation) _onActivation; + final WindowsNotificationIdStore? _idStore; + + @override + Future notify( + BusyMaxNotificationRequest request, { + DesktopNotificationActionHandler? onAction, + }) async { + const permittedPayloadKeys = { + 'notificationScheduleId', + 'itemKind', + 'accountId', + 'sourceId', + 'itemId', + }; + final requestPayload = request.payload ?? const {}; + if (request.stableId.isEmpty || + request.stableId.length > 2048 || + request.actions.any( + (action) => !const { + 'default', + 'open', + 'snooze', + 'dismiss', + }.contains(action.id), + ) || + requestPayload.entries.any( + (entry) => + !permittedPayloadKeys.contains(entry.key) || + entry.value.isEmpty || + entry.value.length > 2048, + )) { + throw ArgumentError( + 'Windows notification activation data must contain only bounded ' + 'BusyMax identifiers.', + ); + } + final payload = { + 'version': 1, + ...requestPayload, + 'stableId': request.stableId, + }; + final idStore = _idStore; + if (idStore == null) { + throw StateError( + 'The notification activation forwarder cannot display notifications.', + ); + } + final notificationId = await idStore.idFor(request.stableId); + await _plugin.show( + id: notificationId, + title: request.title, + body: request.body, + payload: jsonEncode(payload), + notificationDetails: NotificationDetails( + windows: WindowsNotificationDetails( + actions: request.actions + .map( + (action) => WindowsAction( + content: action.label, + // Windows supplies the selected action's arguments instead + // of the toast's root launch value. Include the opaque + // identifiers in every action so warm and cold activation + // use the same idempotent route. + arguments: jsonEncode({...payload, 'action': action.id}), + ), + ) + .toList(growable: false), + ), + ), + ); + } + + void _handleResponse(NotificationResponse response) { + final parsed = _decodePayload(response.payload); + if (parsed == null) return; + final stableId = parsed.remove('stableId'); + if (stableId == null || stableId != parsed['notificationScheduleId']) { + return; + } + final encodedAction = parsed.remove('action'); + final action = + response.notificationResponseType == + NotificationResponseType.notificationDismissed + ? 'dismiss' + : encodedAction ?? 'default'; + final activation = DesktopActivation( + kind: DesktopActivationKind.notification, + action: action, + payload: parsed, + ); + if (!activation.isValid) return; + // Warm and cold responses deliberately use the same idempotent activation + // router. Calling an in-memory closure here as well would process snooze or + // dismiss twice in the warm-process case. + _onActivation(activation); + } + + Map? _decodePayload(String? encoded) { + if (encoded == null || + encoded.length > DesktopActivation.maximumEncodedBytes) { + return null; + } + try { + final decoded = jsonDecode(encoded); + if (decoded is! Map || decoded['version'] != 1) return null; + final values = {}; + for (final entry in decoded.entries) { + if (entry.key == 'version') continue; + if (entry.key is! String || entry.value is! String) return null; + values[entry.key as String] = entry.value as String; + } + return values; + } on FormatException { + return null; + } + } + + @override + Future cancel(String stableId) async { + final idStore = _idStore; + if (idStore == null) { + throw StateError( + 'The notification activation forwarder cannot cancel notifications.', + ); + } + await _plugin.cancel(id: await idStore.idFor(stableId)); + } + + @override + Future close() async { + await _plugin.dispose(); + } +} + +abstract interface class WindowsNotificationPluginAdapter { + Future initialize({ + required InitializationSettings settings, + DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse, + }); + + Future getNotificationAppLaunchDetails(); + + Future show({ + required int id, + String? title, + String? body, + NotificationDetails? notificationDetails, + String? payload, + }); + + Future cancel({required int id}); + + Future dispose(); +} + +final class FlutterWindowsNotificationPluginAdapter + implements WindowsNotificationPluginAdapter { + FlutterWindowsNotificationPluginAdapter(this.plugin); + + final FlutterLocalNotificationsPlugin plugin; + + @override + Future initialize({ + required InitializationSettings settings, + DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse, + }) => plugin.initialize( + settings: settings, + onDidReceiveNotificationResponse: onDidReceiveNotificationResponse, + ); + + @override + Future getNotificationAppLaunchDetails() => + plugin.getNotificationAppLaunchDetails(); + + @override + Future show({ + required int id, + String? title, + String? body, + NotificationDetails? notificationDetails, + String? payload, + }) => plugin.show( + id: id, + title: title, + body: body, + notificationDetails: notificationDetails, + payload: payload, + ); + + @override + Future cancel({required int id}) => plugin.cancel(id: id); + + @override + Future dispose() async { + plugin + .resolvePlatformSpecificImplementation< + FlutterLocalNotificationsWindows + >() + ?.dispose(); + } +} + +final class WindowsNotificationInitializationException implements Exception { + const WindowsNotificationInitializationException(this.code); + + final String code; +} diff --git a/lib/src/platform/windows/windows_notification_id_store.dart b/lib/src/platform/windows/windows_notification_id_store.dart new file mode 100644 index 00000000..cb4ba6a4 --- /dev/null +++ b/lib/src/platform/windows/windows_notification_id_store.dart @@ -0,0 +1,158 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; + +/// A persistent one-to-one mapping between BusyMax schedule identifiers and +/// the signed 31-bit integer key required by the Windows notification API. +/// Entries are intentionally retained after cancellation so replacement and +/// cancellation remain stable for the lifetime of a BusyMax profile. +final class WindowsNotificationIdStore { + WindowsNotificationIdStore(this.file); + + static const maximumId = 0x7fffffff; + final File file; + final _ids = {}; + bool _loaded = false; + int _nextId = 1; + Future _serial = Future.value(); + + static Future openDefault() async { + final directory = await getApplicationSupportDirectory(); + return WindowsNotificationIdStore( + File(path.join(directory.path, 'windows_notification_ids.json')), + ); + } + + Future initialize() { + final completer = Completer(); + _serial = _serial.then((_) async { + try { + await _load(); + completer.complete(); + } on Object catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }); + return completer.future; + } + + Future idFor(String notificationScheduleId) { + if (notificationScheduleId.isEmpty || + notificationScheduleId.length > 2048) { + throw ArgumentError.value( + notificationScheduleId, + 'notificationScheduleId', + 'The notification schedule identifier is invalid.', + ); + } + final completer = Completer(); + _serial = _serial.then((_) async { + try { + await _load(); + final existing = _ids[notificationScheduleId]; + if (existing != null) { + completer.complete(existing); + return; + } + if (_nextId > maximumId) { + throw StateError('The Windows notification ID space is exhausted.'); + } + final id = _nextId++; + _ids[notificationScheduleId] = id; + await _persist(); + completer.complete(id); + } on Object catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }); + return completer.future; + } + + Future _load() async { + if (_loaded) return; + final backup = File('${file.path}.bak'); + if (!await file.exists() && await backup.exists()) { + await backup.rename(file.path); + } + if (!await file.exists()) { + _loaded = true; + return; + } + final Object? decoded; + try { + decoded = jsonDecode(await file.readAsString()); + } on Object { + throw const FormatException( + 'The Windows notification ID store is invalid.', + ); + } + if (decoded is! Map || decoded['version'] != 1) { + throw const FormatException( + 'The Windows notification ID store has an unsupported format.', + ); + } + final rawIds = decoded['ids']; + if (rawIds is! Map) { + throw const FormatException( + 'The Windows notification ID store has no ID map.', + ); + } + final loadedIds = {}; + final used = {}; + for (final entry in rawIds.entries) { + final id = entry.value; + if (entry.key.isEmpty || + entry.key.length > 2048 || + id is! int || + id < 1 || + id > maximumId || + !used.add(id)) { + throw const FormatException( + 'The Windows notification ID store contains invalid IDs.', + ); + } + loadedIds[entry.key] = id; + } + final storedNext = decoded['nextId']; + final minimumNext = used.isEmpty + ? 1 + : used.reduce((left, right) => left > right ? left : right) + 1; + if (storedNext is! int || + storedNext < minimumNext || + storedNext > maximumId + 1) { + throw const FormatException( + 'The Windows notification ID store has an invalid sequence.', + ); + } + _ids + ..clear() + ..addAll(loadedIds); + _nextId = storedNext; + _loaded = true; + } + + Future _persist() async { + await file.parent.create(recursive: true); + final temporary = File('${file.path}.new'); + final backup = File('${file.path}.bak'); + if (await temporary.exists()) await temporary.delete(); + await temporary.writeAsString( + jsonEncode({'version': 1, 'nextId': _nextId, 'ids': _ids}), + flush: true, + ); + if (await backup.exists()) await backup.delete(); + if (await file.exists()) await file.rename(backup.path); + try { + await temporary.rename(file.path); + } on Object { + if (!await file.exists() && await backup.exists()) { + await backup.rename(file.path); + } + rethrow; + } + if (await backup.exists()) await backup.delete(); + } +} diff --git a/lib/src/platform/windows/windows_secret_storage_presentation.dart b/lib/src/platform/windows/windows_secret_storage_presentation.dart new file mode 100644 index 00000000..d86359b0 --- /dev/null +++ b/lib/src/platform/windows/windows_secret_storage_presentation.dart @@ -0,0 +1,9 @@ +import '../../core/secrets/secret_store.dart'; + +const windowsSecretStoragePresentation = SecretStoragePresentation( + backendDomain: 'busymax.secure_storage.windows', + runtimeBackend: 'windows-credential-storage', + unavailableMessage: + 'Windows credential storage is temporarily unavailable. Check your ' + 'Windows credentials and try again.', +); diff --git a/lib/src/platform/windows/windows_system_appearance_source.dart b/lib/src/platform/windows/windows_system_appearance_source.dart new file mode 100644 index 00000000..437bf574 --- /dev/null +++ b/lib/src/platform/windows/windows_system_appearance_source.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:system_theme/system_theme.dart'; + +import '../common/desktop_services.dart'; + +final class WindowsSystemAppearanceSource implements SystemAppearanceSource { + WindowsSystemAppearanceSource() { + _subscription = SystemTheme.onChange.listen((_) => _changes.add(null)); + } + + final _changes = StreamController.broadcast(); + late final StreamSubscription _subscription; + + @override + Color get accentColor => SystemTheme.accentColor.accent; + + @override + Brightness get brightness => + WidgetsBinding.instance.platformDispatcher.platformBrightness; + + @override + bool get highContrast => WidgetsBinding + .instance + .platformDispatcher + .accessibilityFeatures + .highContrast; + + @override + Stream get changes => _changes.stream; + + Future dispose() async { + await _subscription.cancel(); + await _changes.close(); + } +} diff --git a/lib/src/platform/windows/windows_time_zone_source.dart b/lib/src/platform/windows/windows_time_zone_source.dart new file mode 100644 index 00000000..149c9a02 --- /dev/null +++ b/lib/src/platform/windows/windows_time_zone_source.dart @@ -0,0 +1,133 @@ +import 'package:flutter_timezone/flutter_timezone.dart'; +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; +import 'package:timezone/data/latest_all.dart' as time_zone_data; +import 'package:timezone/timezone.dart' as time_zone; + +import '../common/desktop_services.dart'; + +/// Reads the current Windows zone and normalizes it to the IANA identifiers +/// used by BusyMax and package:timezone. +final class WindowsLocalTimeZoneSource implements LocalTimeZoneSource { + WindowsLocalTimeZoneSource({Future Function()? load}) + : _load = load ?? FlutterTimezone.getLocalTimezone; + + final Future Function() _load; + final _logger = Logger('busymax.windows.timezone'); + String? _lastUnrecognizedIdentifier; + String? _lastFailureCode; + + String? get lastUnrecognizedIdentifier => _lastUnrecognizedIdentifier; + + @override + String? get diagnostic { + final failureCode = _lastFailureCode; + if (failureCode != null) { + return 'local-time-zone/$failureCode; fallback=Etc/UTC'; + } + final identifier = _lastUnrecognizedIdentifier; + return identifier == null + ? null + : 'local-time-zone/unrecognized=$identifier; fallback=Etc/UTC'; + } + + @override + Future currentIanaTimeZone() async { + try { + final result = await _load(); + final identifier = result.identifier.trim(); + time_zone_data.initializeTimeZones(); + if (time_zone.timeZoneDatabase.locations.containsKey(identifier)) { + _lastUnrecognizedIdentifier = null; + _lastFailureCode = null; + return identifier == 'UTC' ? 'Etc/UTC' : identifier; + } + final mapped = windowsToIanaTimeZones[identifier]; + if (mapped != null) { + _lastUnrecognizedIdentifier = null; + _lastFailureCode = null; + return mapped; + } + _lastUnrecognizedIdentifier = + identifier.length <= 128 && + RegExp(r'^[A-Za-z0-9_+./ -]+$').hasMatch(identifier) + ? identifier + : ''; + _lastFailureCode = null; + _logger.warning('Unrecognized Windows time zone; using Etc/UTC.'); + } on PlatformException catch (error) { + const permittedCodes = { + 'windows-timezone-query-failed', + 'windows-timezone-key-invalid', + 'windows-region-unavailable', + 'windows-region-empty', + 'windows-region-encoding-failed', + 'icu-timezone-mapping-failed', + 'icu-timezone-mapping-missing', + 'icu-timezone-mapping-too-long', + 'icu-timezone-encoding-failed', + 'timezone-native-api-unavailable', + }; + _lastUnrecognizedIdentifier = null; + _lastFailureCode = permittedCodes.contains(error.code) + ? error.code + : 'unavailable'; + _logger.warning('Windows time zone is unavailable; using Etc/UTC.'); + } on Object { + _lastUnrecognizedIdentifier = null; + _lastFailureCode = 'unavailable'; + _logger.warning('Windows time zone is unavailable; using Etc/UTC.'); + } + return 'Etc/UTC'; + } +} + +/// Stable CLDR-compatible primary-zone mappings for Windows identifiers. The +/// plugin normally returns IANA on current Windows releases; this table covers +/// native Windows IDs returned by older plugin/OS combinations without guessing. +const windowsToIanaTimeZones = { + 'Dateline Standard Time': 'Etc/GMT+12', + 'UTC-11': 'Etc/GMT+11', + 'Aleutian Standard Time': 'America/Adak', + 'Hawaiian Standard Time': 'Pacific/Honolulu', + 'Alaskan Standard Time': 'America/Anchorage', + 'Pacific Standard Time': 'America/Los_Angeles', + 'US Mountain Standard Time': 'America/Phoenix', + 'Mountain Standard Time': 'America/Denver', + 'Central Standard Time': 'America/Chicago', + 'Eastern Standard Time': 'America/New_York', + 'Atlantic Standard Time': 'America/Halifax', + 'Newfoundland Standard Time': 'America/St_Johns', + 'SA Eastern Standard Time': 'America/Cayenne', + 'Greenland Standard Time': 'America/Nuuk', + 'UTC': 'Etc/UTC', + 'GMT Standard Time': 'Europe/London', + 'W. Europe Standard Time': 'Europe/Berlin', + 'Central Europe Standard Time': 'Europe/Budapest', + 'Romance Standard Time': 'Europe/Paris', + 'FLE Standard Time': 'Europe/Kyiv', + 'Turkey Standard Time': 'Europe/Istanbul', + 'Israel Standard Time': 'Asia/Jerusalem', + 'Egypt Standard Time': 'Africa/Cairo', + 'South Africa Standard Time': 'Africa/Johannesburg', + 'Russian Standard Time': 'Europe/Moscow', + 'Arabian Standard Time': 'Asia/Dubai', + 'Iran Standard Time': 'Asia/Tehran', + 'Afghanistan Standard Time': 'Asia/Kabul', + 'Pakistan Standard Time': 'Asia/Karachi', + 'India Standard Time': 'Asia/Kolkata', + 'Nepal Standard Time': 'Asia/Kathmandu', + 'Bangladesh Standard Time': 'Asia/Dhaka', + 'Myanmar Standard Time': 'Asia/Yangon', + 'SE Asia Standard Time': 'Asia/Bangkok', + 'China Standard Time': 'Asia/Shanghai', + 'Tokyo Standard Time': 'Asia/Tokyo', + 'Korea Standard Time': 'Asia/Seoul', + 'AUS Central Standard Time': 'Australia/Darwin', + 'AUS Eastern Standard Time': 'Australia/Sydney', + 'Tasmania Standard Time': 'Australia/Hobart', + 'West Pacific Standard Time': 'Pacific/Port_Moresby', + 'New Zealand Standard Time': 'Pacific/Auckland', + 'Chatham Islands Standard Time': 'Pacific/Chatham', + 'Tonga Standard Time': 'Pacific/Tongatapu', +}; diff --git a/lib/src/platform/windows/windows_tray_service.dart b/lib/src/platform/windows/windows_tray_service.dart new file mode 100644 index 00000000..0764058d --- /dev/null +++ b/lib/src/platform/windows/windows_tray_service.dart @@ -0,0 +1,242 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; +import 'package:tray_manager/tray_manager.dart'; + +import '../../features/tray/domain/tray_presentation.dart'; +import '../common/desktop_services.dart'; + +enum WindowsTrayCommand { + open, + newEvent, + newTask, + today, + synchronize, + settings, + quit, +} + +final class WindowsTrayService with TrayListener implements DesktopTrayService { + WindowsTrayService({ + required Future Function() loadPresentation, + required Future Function(WindowsTrayCommand command) onCommand, + this.onUnavailable, + this.iconPath = 'assets/windows/busymax_tray.ico', + this.offlineIconPath = 'assets/windows/busymax_tray_offline.ico', + }) : _loadPresentation = loadPresentation, + _onCommand = onCommand; + + final Future Function() _loadPresentation; + final Future Function(WindowsTrayCommand command) _onCommand; + final Future Function(String? errorCode)? onUnavailable; + final String iconPath; + final String offlineIconPath; + bool _available = false; + bool _listenerAttached = false; + bool _healthHandlerAttached = false; + String? _lastFailure; + final _logger = Logger('busymax.windows.tray'); + static const _healthChannel = MethodChannel('busymax/windows/tray_health'); + + @override + bool get isAvailable => _available; + + String? get lastFailure => _lastFailure; + + @override + Future start() async { + if (_available) return true; + try { + if (!_listenerAttached) { + trayManager.addListener(this); + _listenerAttached = true; + } + if (!_healthHandlerAttached) { + _healthChannel.setMethodCallHandler(_handleHealthEvent); + _healthHandlerAttached = true; + } + await trayManager.setIcon(iconPath); + await trayManager.setToolTip('BusyMax'); + _available = true; + _lastFailure = null; + await refresh(); + return _available; + } on Object { + _lastFailure = 'tray-initialization-failed'; + _logger.warning('The Windows tray could not be initialized.'); + _removeListener(); + _available = false; + await onUnavailable?.call(_lastFailure); + return false; + } + } + + @override + Future refresh() async { + if (!_available) return; + try { + final presentation = await _loadPresentation(); + await trayManager.setContextMenu(_menu(presentation)); + await trayManager.setToolTip( + presentation.offline + ? 'BusyMax — ${presentation.offlineTitle}' + : 'BusyMax', + ); + // Reapplying the icon also recovers the tray after Explorer recreates + // its notification area. + await trayManager.setIcon( + presentation.offline ? offlineIconPath : iconPath, + ); + } on Object { + _lastFailure = 'tray-refresh-failed'; + _logger.warning('The Windows tray could not be refreshed.'); + _removeListener(); + _available = false; + try { + await trayManager.destroy(); + } on Object { + // Explorer may already have discarded the old tray host. + } + await onUnavailable?.call(_lastFailure); + } + } + + Future _handleHealthEvent(MethodCall call) async { + if (call.method != 'onTrayAvailabilityChanged') return null; + final arguments = call.arguments; + if (arguments is! Map) return null; + final available = arguments['available'] == true; + _available = available; + if (available) { + _lastFailure = null; + return null; + } + final error = arguments['errorCode']; + _lastFailure = + error is String && + error.length <= 96 && + RegExp(r'^tray-[a-z0-9-]+$').hasMatch(error) + ? error + : 'tray-native-recovery-failed'; + _logger.warning('The Windows tray became unavailable.'); + await onUnavailable?.call(_lastFailure); + return null; + } + + Menu _menu(BusyMaxTrayMenuPresentation value) { + MenuItem item( + String key, + String label, { + bool enabled = true, + WindowsTrayCommand? command, + }) => MenuItem( + key: key, + label: label, + disabled: !enabled, + onClick: command == null ? null : (_) => unawaited(_onCommand(command)), + ); + + return Menu( + items: [ + item('open', value.showBusyMaxLabel, command: WindowsTrayCommand.open), + MenuItem.separator(), + item( + 'new-event', + value.newEventLabel, + enabled: value.canCreateEvent, + command: WindowsTrayCommand.newEvent, + ), + item( + 'new-task', + value.newTaskLabel, + enabled: value.canCreateTask, + command: WindowsTrayCommand.newTask, + ), + MenuItem.separator(), + item('today-label', value.todayLabel), + ...value.eventRows.map( + (row) => item( + 'event:${row.event.eventId}', + row.label, + command: WindowsTrayCommand.today, + ), + ), + if (value.taskSummaryLabel != null) + item( + 'tasks-today', + value.taskSummaryLabel!, + command: WindowsTrayCommand.today, + ), + if (value.showEmptyState) item('empty', value.emptyStateLabel), + item( + 'today', + value.openTodayAgendaLabel, + command: WindowsTrayCommand.today, + ), + MenuItem.separator(), + item( + 'sync', + value.syncNowLabel, + enabled: value.canSynchronize, + command: WindowsTrayCommand.synchronize, + ), + item('sync-status', value.synchronizationStatusLabel), + MenuItem.separator(), + item( + 'settings', + value.settingsLabel, + command: WindowsTrayCommand.settings, + ), + item('quit', value.quitBusyMaxLabel, command: WindowsTrayCommand.quit), + ], + ); + } + + @override + void onTrayIconMouseDown() { + unawaited(_onCommand(WindowsTrayCommand.open)); + } + + @override + void onTrayIconRightMouseDown() { + unawaited(_showContextMenu()); + } + + Future _showContextMenu() async { + try { + await trayManager.popUpContextMenu(); + } on Object { + _lastFailure = 'tray-menu-display-failed'; + _logger.warning('The Windows tray menu could not be displayed.'); + await onUnavailable?.call(_lastFailure); + } + } + + @override + void onTrayMenuItemClick(MenuItem menuItem) { + // MenuItem callbacks are used so the adapter also works on plugin builds + // that do not emit this listener callback on Windows. + } + + @override + Future stop() async { + _removeListener(); + try { + await trayManager.destroy(); + } on Object { + // Quit must not be blocked by an unavailable Explorer tray host. + } + _available = false; + if (_healthHandlerAttached) { + _healthChannel.setMethodCallHandler(null); + _healthHandlerAttached = false; + } + } + + void _removeListener() { + if (!_listenerAttached) return; + trayManager.removeListener(this); + _listenerAttached = false; + } +} diff --git a/lib/src/platform/windows/windows_window_service.dart b/lib/src/platform/windows/windows_window_service.dart new file mode 100644 index 00000000..f9801c5e --- /dev/null +++ b/lib/src/platform/windows/windows_window_service.dart @@ -0,0 +1,33 @@ +import 'package:flutter/services.dart'; + +import '../common/desktop_services.dart'; + +const windowsDesktopChannelName = 'org.busystack.busymax/windows_desktop'; + +final class WindowsWindowService implements DesktopWindowService { + const WindowsWindowService({ + MethodChannel channel = const MethodChannel(windowsDesktopChannelName), + }) : _channel = channel; + + final MethodChannel _channel; + + Future reportNotificationActivationFailure() => + _channel.invokeMethod('reportNotificationActivationFailure'); + + @override + Future hideWindow() => _channel.invokeMethod('hide'); + + @override + Future isWindowVisible() async => + await _channel.invokeMethod('isVisible') ?? true; + + @override + Future quitApp() => _channel.invokeMethod('quit'); + + @override + Future setHideOnClose(bool enabled) => + _channel.invokeMethod('setHideOnClose', enabled); + + @override + Future showWindow() => _channel.invokeMethod('showAndFocus'); +} diff --git a/lib/src/providers/account_authority.dart b/lib/src/providers/account_authority.dart index 640833af..69394e30 100644 --- a/lib/src/providers/account_authority.dart +++ b/lib/src/providers/account_authority.dart @@ -30,7 +30,8 @@ String normalizeProviderAccountId(BusyProvider provider, String value) { BusyProvider.appleICloud => trimmed.toLowerCase(), BusyProvider.google || BusyProvider.microsoft || - BusyProvider.nextcloud => trimmed, + BusyProvider.nextcloud || + BusyProvider.webCal => trimmed, }; } @@ -49,9 +50,36 @@ String normalizeAccountAuthority( 'Nextcloud requires the canonical server returned by Login Flow v2.', )), ), + BusyProvider.webCal => _safeHttpsOrigin( + authority ?? + (throw const InvalidAccountAuthorityException( + 'WebCal requires a safe HTTPS origin.', + )), + ), }; } +String _safeHttpsOrigin(String value) { + final uri = Uri.tryParse(value.trim()); + if (uri == null || + uri.scheme != 'https' || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.hasQuery || + uri.hasFragment || + (uri.path.isNotEmpty && uri.path != '/')) { + throw InvalidAccountAuthorityException( + 'A WebCal authority must be a safe HTTPS origin.', + value, + ); + } + return Uri( + scheme: 'https', + host: uri.host.toLowerCase(), + port: uri.hasPort && uri.port != 443 ? uri.port : null, + ).toString(); +} + String normalizeNextcloudServerAuthority(String value) { final trimmed = value.trim(); final parsed = Uri.tryParse(trimmed); diff --git a/lib/src/providers/busy_provider.dart b/lib/src/providers/busy_provider.dart index 2c9b0f53..4e13952b 100644 --- a/lib/src/providers/busy_provider.dart +++ b/lib/src/providers/busy_provider.dart @@ -1,5 +1,5 @@ /// Stable account provider identities persisted by BusyMax. -enum BusyProvider { google, microsoft, appleICloud, nextcloud } +enum BusyProvider { google, microsoft, appleICloud, nextcloud, webCal } extension BusyProviderValue on BusyProvider { String get storageValue => switch (this) { @@ -7,6 +7,7 @@ extension BusyProviderValue on BusyProvider { BusyProvider.microsoft => 'microsoft', BusyProvider.appleICloud => 'apple_icloud', BusyProvider.nextcloud => 'nextcloud', + BusyProvider.webCal => 'webcal', }; String get displayName => switch (this) { @@ -14,6 +15,7 @@ extension BusyProviderValue on BusyProvider { BusyProvider.microsoft => 'Microsoft', BusyProvider.appleICloud => 'Apple iCloud', BusyProvider.nextcloud => 'Nextcloud', + BusyProvider.webCal => 'WebCal', }; } @@ -70,6 +72,7 @@ abstract final class BusyProviderCodec { 'microsoft' => const SupportedBusyProvider(BusyProvider.microsoft), 'apple_icloud' => const SupportedBusyProvider(BusyProvider.appleICloud), 'nextcloud' => const SupportedBusyProvider(BusyProvider.nextcloud), + 'webcal' => const SupportedBusyProvider(BusyProvider.webCal), _ => UnsupportedStoredProvider(value), }; } diff --git a/lib/src/schedule/schedule_item.dart b/lib/src/schedule/schedule_item.dart index 60cff80a..d6c954ed 100644 --- a/lib/src/schedule/schedule_item.dart +++ b/lib/src/schedule/schedule_item.dart @@ -59,6 +59,8 @@ class CalendarScheduleItem implements ScheduleItem { this.organizer, this.joinMeetingUrl, this.isOrganizer, + this.guestsCanModify, + this.locked = false, this.currentUserResponse, this.colorHex, this.categories = const [], @@ -95,6 +97,8 @@ class CalendarScheduleItem implements ScheduleItem { final Map? organizer; final String? joinMeetingUrl; final bool? isOrganizer; + final bool? guestsCanModify; + final bool locked; final String? currentUserResponse; final String? colorHex; @override diff --git a/lib/src/schedule/schedule_projection.dart b/lib/src/schedule/schedule_projection.dart index 21f3e9d2..3d40ab37 100644 --- a/lib/src/schedule/schedule_projection.dart +++ b/lib/src/schedule/schedule_projection.dart @@ -146,6 +146,7 @@ class ScheduleProjection { BusyProvider.microsoft => _dedupeProvider('Microsoft To Do', listName), BusyProvider.appleICloud => _dedupeProvider('Apple iCloud', listName), BusyProvider.nextcloud => _dedupeProvider('Nextcloud Tasks', listName), + BusyProvider.webCal => listName, }; } return 'BusyMax'; diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index 54312796..495f5b05 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -17,9 +17,13 @@ import 'schedule_range.dart'; import 'schedule_sorting.dart'; class ScheduleRepository { - const ScheduleRepository(this._database); + const ScheduleRepository( + this._database, { + Future Function(ScheduleRange range)? ensureProjectionCoverage, + }) : _ensureProjectionCoverage = ensureProjectionCoverage; final AppDatabase _database; + final Future Function(ScheduleRange range)? _ensureProjectionCoverage; Future findTaskTarget({ required String accountId, @@ -101,6 +105,9 @@ class ScheduleRepository { required ScheduleRange range, ScheduleFilters filters = const ScheduleFilters(), }) async { + if (filters.includeCalendarEvents) { + await _ensureProjectionCoverage?.call(range); + } final context = await _accountContext(filters); if (context == null) { return const []; @@ -138,6 +145,41 @@ class ScheduleRepository { return filtered; } + /// Lists every visible task without applying a calendar date window. + /// + /// The Tasks workspace is a complete task collection, unlike the Schedule + /// workspace, whose projections are intentionally bounded by a visible + /// range. Keeping this query here also ensures every platform observes the + /// same account, source, completion, search, and hierarchy rules. + Future> listAllTasks({ + ScheduleFilters filters = const ScheduleFilters( + includeCalendarEvents: false, + showCompletedTasks: true, + showNoDateTasks: true, + ), + }) async { + if (!filters.includeTasks) return const []; + final context = await _accountContext(filters); + if (context == null) return const []; + + // `_taskItems` ignores this range when `searching` is true. Here that flag + // deliberately requests the same unbounded query used by global search. + final items = await _taskItems( + ScheduleRange.day(DateTime.now()), + filters, + true, + context.accountIds, + context.providers, + context.accountDisplayNames, + context.accountEmails, + ); + final tasks = items.whereType().where((item) { + return filters.query.trim().isEmpty || + matchesScheduleQuery(item, filters.query); + }).toList()..sort(compareScheduleItems); + return ScheduleProjection.arrangeHierarchy(tasks); + } + Future listOverdueTasks({ required DateTime before, required int limit, @@ -348,7 +390,11 @@ class ScheduleRepository { ]) ..where(_database.calendarEvents.accountId.isIn(accountIds)) ..where(_database.calendarEvents.isDeleted.equals(false)) - ..where(_database.calendarEvents.isCancelled.equals(false)); + ..where(_database.calendarEvents.isCancelled.equals(false)) + ..where( + _database.calendarSources.isDeleted.isNull() | + _database.calendarSources.isDeleted.equals(false), + ); if (filters.sourceFilterActive) { query.where( _database.calendarEvents.calendarSourceId.isIn(filters.sourceIds), @@ -369,6 +415,9 @@ class ScheduleRepository { final organizer = _jsonMapFromString(event.organizerJson); final conference = _jsonValueFromString(event.conferenceJson); final raw = _jsonMapFromString(event.rawJson) ?? const {}; + final isOrganizer = _eventIsOrganizer(provider, organizer, raw); + final sourceWritable = + source != null && !source.readOnly && !source.isDeleted; if (!searching && !_intersects(range, start, end)) { continue; } @@ -391,7 +440,11 @@ class ScheduleRepository { attendees: attendees, organizer: organizer, joinMeetingUrl: _eventJoinMeetingUrl(provider, conference, raw), - isOrganizer: _eventIsOrganizer(provider, organizer, raw), + isOrganizer: isOrganizer, + guestsCanModify: provider == BusyProvider.google + ? raw['guestsCanModify'] == true + : null, + locked: provider == BusyProvider.google && raw['locked'] == true, currentUserResponse: _eventCurrentUserResponse( provider, attendees, @@ -413,9 +466,12 @@ class ScheduleRepository { sourceName: source?.summary, accountDisplayName: accountDisplayNames[event.accountId], accountEmail: accountEmails[event.accountId], - capabilities: source != null && !source.readOnly && !source.isDeleted - ? ScheduleItemCapabilities.editable - : ScheduleItemCapabilities.readOnly, + capabilities: ScheduleItemCapabilities( + canEdit: + sourceWritable && + _eventAllowsFullEditing(provider, isOrganizer, raw), + canDelete: sourceWritable, + ), ), ); } @@ -1171,10 +1227,23 @@ bool? _eventIsOrganizer( return switch (provider) { BusyProvider.google => organizer?['self'] as bool?, BusyProvider.microsoft => raw['isOrganizer'] as bool?, - BusyProvider.appleICloud || BusyProvider.nextcloud => null, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, }; } +bool _eventAllowsFullEditing( + BusyProvider provider, + bool? isOrganizer, + Map raw, +) { + if (provider == BusyProvider.webCal) return false; + if (provider != BusyProvider.google) return true; + return raw['locked'] != true && + (isOrganizer == true || raw['guestsCanModify'] == true); +} + String? _eventCurrentUserResponse( BusyProvider provider, List> attendees, @@ -1221,7 +1290,9 @@ String? _eventJoinMeetingUrl( final fallback = switch (provider) { BusyProvider.google => raw['hangoutLink']?.toString().trim(), BusyProvider.microsoft => raw['onlineMeetingUrl']?.toString().trim(), - BusyProvider.appleICloud || BusyProvider.nextcloud => null, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, }; return _isWebUrl(fallback) ? fallback : null; } @@ -1265,7 +1336,8 @@ List _eventReminderMinutes( _ => const [], }, BusyProvider.appleICloud || - BusyProvider.nextcloud => switch (map['minutes'] ?? map['overrides']) { + BusyProvider.nextcloud || + BusyProvider.webCal => switch (map['minutes'] ?? map['overrides']) { final List values => values, final int value => [value], _ => const [], diff --git a/lib/src/ui/common/busymax_glyph.dart b/lib/src/ui/common/busymax_glyph.dart new file mode 100644 index 00000000..9c6a23ee --- /dev/null +++ b/lib/src/ui/common/busymax_glyph.dart @@ -0,0 +1,29 @@ +enum BusyMaxGlyph { + calendar, + task, + add, + edit, + delete, + refresh, + settings, + account, + sync, + warning, + error, + information, + search, + previous, + next, + today, + month, + agenda, + recurrence, + reminder, + close, + more, + check, + signOut, + diagnostics, + feedback, + privacy, +} diff --git a/lib/src/ui/linux/linux_busymax_glyphs.dart b/lib/src/ui/linux/linux_busymax_glyphs.dart new file mode 100644 index 00000000..e48e5750 --- /dev/null +++ b/lib/src/ui/linux/linux_busymax_glyphs.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:yaru/yaru.dart'; + +import '../common/busymax_glyph.dart'; + +IconData linuxBusyMaxGlyph(BusyMaxGlyph glyph) => switch (glyph) { + BusyMaxGlyph.calendar => YaruIcons.calendar, + BusyMaxGlyph.task => Icons.task_alt_outlined, + BusyMaxGlyph.add => YaruIcons.plus, + BusyMaxGlyph.edit => Icons.edit_outlined, + BusyMaxGlyph.delete => YaruIcons.trash, + BusyMaxGlyph.refresh || BusyMaxGlyph.sync => YaruIcons.refresh, + BusyMaxGlyph.settings => YaruIcons.settings, + BusyMaxGlyph.account => YaruIcons.user, + BusyMaxGlyph.warning => YaruIcons.warning, + BusyMaxGlyph.error => YaruIcons.error, + BusyMaxGlyph.information => Icons.info_outline, + BusyMaxGlyph.search => YaruIcons.search, + BusyMaxGlyph.previous => YaruIcons.arrow_left, + BusyMaxGlyph.next => YaruIcons.arrow_right, + BusyMaxGlyph.today => Icons.calendar_today_outlined, + BusyMaxGlyph.month => Icons.calendar_view_month, + BusyMaxGlyph.agenda => Icons.view_agenda_outlined, + BusyMaxGlyph.recurrence => Icons.repeat, + BusyMaxGlyph.reminder => YaruIcons.bell, + BusyMaxGlyph.close => YaruIcons.window_close, + BusyMaxGlyph.more => YaruIcons.view_more, + BusyMaxGlyph.check => YaruIcons.ok, + BusyMaxGlyph.signOut => YaruIcons.log_out, + BusyMaxGlyph.diagnostics => YaruIcons.monitor, + BusyMaxGlyph.feedback => Icons.feedback_outlined, + BusyMaxGlyph.privacy => YaruIcons.shield, +}; + +String? linuxNativeMenuGlyph(BusyMaxGlyph glyph) => switch (glyph) { + BusyMaxGlyph.calendar || BusyMaxGlyph.today => 'x-office-calendar-symbolic', + BusyMaxGlyph.task || BusyMaxGlyph.check => 'checkbox-checked-symbolic', + BusyMaxGlyph.add => 'list-add-symbolic', + BusyMaxGlyph.edit => 'document-edit-symbolic', + BusyMaxGlyph.delete => 'user-trash-symbolic', + BusyMaxGlyph.refresh || BusyMaxGlyph.sync => 'view-refresh-symbolic', + BusyMaxGlyph.settings => 'preferences-system-symbolic', + BusyMaxGlyph.account => 'avatar-default-symbolic', + BusyMaxGlyph.warning => 'dialog-warning-symbolic', + BusyMaxGlyph.error => 'dialog-error-symbolic', + BusyMaxGlyph.information => 'help-about-symbolic', + BusyMaxGlyph.search => 'system-search-symbolic', + BusyMaxGlyph.reminder => 'preferences-system-notifications-symbolic', + BusyMaxGlyph.privacy => 'security-high-symbolic', + _ => null, +}; diff --git a/lib/src/ui/windows/windows_account_removal_dialog.dart b/lib/src/ui/windows/windows_account_removal_dialog.dart new file mode 100644 index 00000000..08ef1775 --- /dev/null +++ b/lib/src/ui/windows/windows_account_removal_dialog.dart @@ -0,0 +1,62 @@ +import 'package:fluent_ui/fluent_ui.dart'; + +import '../../../l10n/generated/app_localizations.dart'; + +class WindowsAccountRemovalOptions { + const WindowsAccountRemovalOptions({required this.revokeGoogleAuthorization}); + + final bool revokeGoogleAuthorization; +} + +Future showWindowsAccountRemovalDialog( + BuildContext context, { + required String accountLabel, + required bool canRevokeGoogleAuthorization, +}) { + var revoke = false; + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + return ContentDialog( + title: Text(l10n.removeAccountTitle(accountLabel)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.removeAccountConfirmation), + if (canRevokeGoogleAuthorization) ...[ + const SizedBox(height: 16), + Checkbox( + checked: revoke, + onChanged: (value) => setState(() => revoke = value ?? false), + content: Text(l10n.revokeGoogleAccess), + ), + const SizedBox(height: 4), + Text( + l10n.revokeGoogleAccessDescription, + style: FluentTheme.of(context).typography.caption, + ), + ], + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop( + dialogContext, + WindowsAccountRemovalOptions(revokeGoogleAuthorization: revoke), + ), + child: Text(l10n.removeAccountAction), + ), + ], + ); + }, + ), + ); +} diff --git a/lib/src/ui/windows/windows_busymax_glyphs.dart b/lib/src/ui/windows/windows_busymax_glyphs.dart new file mode 100644 index 00000000..ab0a77e1 --- /dev/null +++ b/lib/src/ui/windows/windows_busymax_glyphs.dart @@ -0,0 +1,34 @@ +import 'package:fluent_ui/fluent_ui.dart' show IconData; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; + +import '../common/busymax_glyph.dart'; + +IconData windowsBusyMaxGlyph(BusyMaxGlyph glyph) => switch (glyph) { + BusyMaxGlyph.calendar => FluentIcons.calendar_24_regular, + BusyMaxGlyph.task => FluentIcons.clipboard_task_24_regular, + BusyMaxGlyph.add => FluentIcons.add_24_regular, + BusyMaxGlyph.edit => FluentIcons.edit_24_regular, + BusyMaxGlyph.delete => FluentIcons.delete_24_regular, + BusyMaxGlyph.refresh || + BusyMaxGlyph.sync => FluentIcons.arrow_sync_24_regular, + BusyMaxGlyph.settings => FluentIcons.settings_24_regular, + BusyMaxGlyph.account => FluentIcons.person_24_regular, + BusyMaxGlyph.warning => FluentIcons.warning_24_regular, + BusyMaxGlyph.error => FluentIcons.error_circle_24_regular, + BusyMaxGlyph.information => FluentIcons.info_24_regular, + BusyMaxGlyph.search => FluentIcons.search_24_regular, + BusyMaxGlyph.previous => FluentIcons.arrow_left_24_regular, + BusyMaxGlyph.next => FluentIcons.arrow_right_24_regular, + BusyMaxGlyph.today => FluentIcons.calendar_today_24_regular, + BusyMaxGlyph.month => FluentIcons.calendar_month_24_regular, + BusyMaxGlyph.agenda => FluentIcons.calendar_agenda_24_regular, + BusyMaxGlyph.recurrence => FluentIcons.arrow_repeat_all_24_regular, + BusyMaxGlyph.reminder => FluentIcons.clock_alarm_24_regular, + BusyMaxGlyph.close => FluentIcons.dismiss_24_regular, + BusyMaxGlyph.more => FluentIcons.more_horizontal_24_regular, + BusyMaxGlyph.check => FluentIcons.checkmark_24_regular, + BusyMaxGlyph.signOut => FluentIcons.sign_out_24_regular, + BusyMaxGlyph.diagnostics => FluentIcons.clipboard_error_24_regular, + BusyMaxGlyph.feedback => FluentIcons.comment_24_regular, + BusyMaxGlyph.privacy => FluentIcons.shield_24_regular, +}; diff --git a/lib/src/ui/windows/windows_calendar_activation_flows.dart b/lib/src/ui/windows/windows_calendar_activation_flows.dart new file mode 100644 index 00000000..d7a980ab --- /dev/null +++ b/lib/src/ui/windows/windows_calendar_activation_flows.dart @@ -0,0 +1,269 @@ +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../features/calendar/data/calendar_repository.dart'; +import '../../ical/ical_import_service.dart'; +import '../../ical/ical_ingestion.dart'; + +Future showWindowsIcsImportFlow( + BuildContext context, + WidgetRef ref, { + String? filePath, +}) async { + const typeGroup = XTypeGroup( + label: 'iCalendar', + extensions: ['ics'], + mimeTypes: ['text/calendar'], + ); + final selected = filePath == null + ? await openFile(acceptedTypeGroups: const [typeGroup]) + : XFile(filePath); + if (selected == null || !context.mounted) return; + try { + if (await selected.length() > icalIngestionDecodedBodyLimit) { + throw const IcalIngestionException( + 'IcalBodyTooLarge', + 'The calendar data exceeds the 16 MiB limit.', + ); + } + final service = ref.read(icalImportServiceProvider); + final preview = service.parsePreview(await selected.readAsBytes()); + final destinations = await service.writableDestinations(); + if (!context.mounted) return; + final destination = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => _WindowsIcsPreviewDialog( + preview: preview, + destinations: destinations, + ), + ); + if (destination == null || !context.mounted) return; + final report = await service.importPreview( + preview: preview, + destination: destination, + ); + if (!context.mounted) return; + await showDialog( + context: context, + builder: (context) => _WindowsIcsReportDialog(report: report), + ); + } on Object catch (error) { + if (!context.mounted) return; + final code = switch (error) { + IcalIngestionException(:final code) => code, + FileSystemException(:final osError) => + osError?.errorCode.toString() ?? 'FileError', + _ => 'IcalImportFailed', + }; + await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(AppLocalizations.of(context).importIcsPreview), + content: InfoBar( + title: Text(AppLocalizations.of(context).importIcsFailed(code)), + severity: InfoBarSeverity.error, + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context).close), + ), + ], + ), + ); + } +} + +class _WindowsIcsPreviewDialog extends StatefulWidget { + const _WindowsIcsPreviewDialog({ + required this.preview, + required this.destinations, + }); + + final IcalImportPreview preview; + final List destinations; + + @override + State<_WindowsIcsPreviewDialog> createState() => + _WindowsIcsPreviewDialogState(); +} + +class _WindowsIcsPreviewDialogState extends State<_WindowsIcsPreviewDialog> { + CalendarSourceEntity? _destination; + + @override + void initState() { + super.initState(); + _destination = widget.destinations.firstOrNull; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final omitted = widget.preview.fieldsThatWillBeOmitted.toList()..sort(); + return ContentDialog( + title: Text(l10n.importIcsPreview), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.importEventsFound(widget.preview.eventCount)), + if (widget.preview.invalidEventCount > 0) + Text(l10n.importInvalidEvents(widget.preview.invalidEventCount)), + if (omitted.isNotEmpty) + Text(l10n.importFieldsOmitted(omitted.join(', '))), + const SizedBox(height: 16), + if (widget.destinations.isEmpty) + InfoBar( + title: Text(l10n.noWritableCalendars), + severity: InfoBarSeverity.warning, + ) + else + InfoLabel( + label: l10n.importDestinationCalendar, + child: ComboBox( + isExpanded: true, + value: _destination, + items: [ + for (final source in widget.destinations) + ComboBoxItem(value: source, child: Text(source.summary)), + ], + onChanged: (value) => setState(() => _destination = value), + ), + ), + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: _destination == null + ? null + : () => Navigator.pop(context, _destination), + child: Text(l10n.importIcsConfirm), + ), + ], + ); + } +} + +class _WindowsIcsReportDialog extends StatelessWidget { + const _WindowsIcsReportDialog({required this.report}); + + final IcalImportReport report; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final omitted = report.fieldsIntentionallyOmitted.toList()..sort(); + return ContentDialog( + title: Text(l10n.importIcsComplete), + content: Text( + [ + l10n.importQueued(report.queued), + l10n.importDuplicatesSkipped(report.duplicatesSkipped), + l10n.importUnsupportedSets(report.unsupportedRecurrenceSets.length), + l10n.importInvalidEvents(report.invalidEvents), + if (omitted.isNotEmpty) l10n.importFieldsOmitted(omitted.join(', ')), + ].join('\n'), + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + ); + } +} + +Future showWindowsWebCalSubscriptionFlow( + BuildContext context, + WidgetRef ref, { + String? initialUrl, +}) async { + final url = TextEditingController(text: initialUrl); + final name = TextEditingController(); + var busy = false; + String? error; + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + return ContentDialog( + title: Text(l10n.addCalendarSubscription), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.calendarSubscriptionsDescription), + const SizedBox(height: 12), + InfoLabel( + label: l10n.subscriptionUrl, + child: TextBox(controller: url, enabled: !busy), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.subscriptionName, + child: TextBox(controller: name, enabled: !busy), + ), + if (error != null) ...[ + const SizedBox(height: 12), + InfoBar(title: Text(error!), severity: InfoBarSeverity.error), + ], + ], + ), + actions: [ + Button( + onPressed: busy ? null : () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: busy || url.text.trim().isEmpty + ? null + : () async { + setState(() { + busy = true; + error = null; + }); + try { + await ref + .read(webCalSubscriptionServiceProvider) + .addSubscription( + subscriptionUrl: url.text.trim(), + localName: name.text.trim().isEmpty + ? null + : name.text.trim(), + ); + if (dialogContext.mounted) Navigator.pop(dialogContext); + } on Object catch (_) { + setState(() { + busy = false; + error = l10n.subscriptionOperationFailed( + l10n.operationFailed, + ); + }); + } + }, + child: busy + ? const SizedBox(width: 16, height: 16, child: ProgressRing()) + : Text(l10n.addSubscriptionAction), + ), + ], + ); + }, + ), + ); + url.dispose(); + name.dispose(); +} diff --git a/lib/src/ui/windows/windows_dav_account_dialogs.dart b/lib/src/ui/windows/windows_dav_account_dialogs.dart new file mode 100644 index 00000000..5e2b9e5b --- /dev/null +++ b/lib/src/ui/windows/windows_dav_account_dialogs.dart @@ -0,0 +1,154 @@ +import 'package:fluent_ui/fluent_ui.dart'; + +import '../../../l10n/generated/app_localizations.dart'; + +class WindowsAppleCredentialInput { + const WindowsAppleCredentialInput({ + required this.email, + required this.password, + }); + + final String email; + final String password; +} + +Future showWindowsAppleICloudDialog( + BuildContext context, +) async { + final email = TextEditingController(); + final password = TextEditingController(); + var validationAttempted = false; + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + final invalid = + validationAttempted && + (email.text.trim().isEmpty || password.text.trim().isEmpty); + return ContentDialog( + title: Text(l10n.connectAppleICloudTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InfoLabel( + label: l10n.appleAccountEmail, + child: TextBox(controller: email, autofocus: true), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.appleAppSpecificPassword, + child: TextBox( + controller: password, + obscureText: true, + enableSuggestions: false, + ), + ), + const SizedBox(height: 8), + Text( + l10n.appleAppSpecificPasswordHelp, + style: FluentTheme.of(context).typography.caption, + ), + if (invalid) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text(l10n.requiredField), + severity: InfoBarSeverity.warning, + ), + ], + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () { + if (email.text.trim().isEmpty || password.text.trim().isEmpty) { + setState(() => validationAttempted = true); + return; + } + Navigator.pop( + dialogContext, + WindowsAppleCredentialInput( + email: email.text.trim(), + password: password.text, + ), + ); + }, + child: Text(l10n.connectAccountAction), + ), + ], + ); + }, + ), + ); + email.dispose(); + password.dispose(); + return result; +} + +Future showWindowsNextcloudServerDialog(BuildContext context) async { + final server = TextEditingController(); + var validationAttempted = false; + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + return ContentDialog( + title: Text(l10n.connectNextcloudTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InfoLabel( + label: l10n.nextcloudServerUrl, + child: TextBox(controller: server, autofocus: true), + ), + const SizedBox(height: 8), + Text( + l10n.nextcloudServerUrlHelp, + style: FluentTheme.of(context).typography.caption, + ), + const SizedBox(height: 8), + Text( + l10n.nextcloudBrowserAuthorizationHelp, + style: FluentTheme.of(context).typography.caption, + ), + if (validationAttempted && server.text.trim().isEmpty) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text(l10n.requiredField), + severity: InfoBarSeverity.warning, + ), + ], + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () { + if (server.text.trim().isEmpty) { + setState(() => validationAttempted = true); + return; + } + Navigator.pop(dialogContext, server.text.trim()); + }, + child: Text(l10n.connectAccountAction), + ), + ], + ); + }, + ), + ); + server.dispose(); + return result; +} diff --git a/lib/src/ui/windows/windows_desktop_runtime.dart b/lib/src/ui/windows/windows_desktop_runtime.dart new file mode 100644 index 00000000..6f91f8dc --- /dev/null +++ b/lib/src/ui/windows/windows_desktop_runtime.dart @@ -0,0 +1,229 @@ +import 'dart:async'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../features/tray/domain/tray_presentation_formatter.dart'; +import '../../platform/common/desktop_services.dart'; +import '../../platform/windows/windows_tray_service.dart'; +import 'windows_event_editor_dialog.dart'; +import 'windows_task_editor_dialog.dart'; + +bool shouldWindowsHideOnClose({ + required bool runInBackgroundWhenClosed, + required bool trayAvailable, +}) => runInBackgroundWhenClosed && trayAvailable; + +class WindowsDesktopRuntime extends ConsumerStatefulWidget { + const WindowsDesktopRuntime({ + required this.child, + required this.startMinimizedAtLaunch, + super.key, + }); + + final Widget child; + final bool startMinimizedAtLaunch; + + @override + ConsumerState createState() => + _WindowsDesktopRuntimeState(); +} + +class _WindowsDesktopRuntimeState extends ConsumerState { + WindowsTrayService? _tray; + BusyMaxTrayPresentationFormatter? _formatter; + Timer? _refreshTimer; + bool _configurationRunning = false; + bool _configurationPending = false; + bool _startMinimizedHandled = false; + String? _localeTag; + + @override + void dispose() { + _refreshTimer?.cancel(); + final tray = _tray; + if (tray != null) unawaited(tray.stop()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final settings = ref.watch(appSettingsControllerProvider); + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + if (_localeTag != locale || _formatter == null) { + _localeTag = locale; + _formatter = BusyMaxTrayPresentationFormatter( + BusyMaxTrayPresentationStrings( + showBusyMax: l10n.trayShowBusyMax, + newEvent: l10n.trayNewEvent, + newTask: l10n.trayNewTask, + today: l10n.trayToday, + allDay: l10n.trayAllDay, + now: l10n.trayNow, + calendarEvent: l10n.trayCalendarEvent, + untitledEvent: l10n.trayUntitledEvent, + nothingElseToday: l10n.trayNothingElseToday, + openTodayAgenda: l10n.trayOpenTodayAgenda, + syncNow: l10n.traySyncNow, + syncing: l10n.traySyncing, + notConnected: l10n.trayNotConnected, + notYetSynced: l10n.trayNotYetSynced, + settings: l10n.traySettings, + quitBusyMax: l10n.trayQuitBusyMax, + offline: l10n.networkOffline, + offlineDescription: l10n.networkOfflineDescription, + formatTime: (value) => DateFormat.jm(locale).format(value), + tasksDueToday: l10n.trayTasksDueToday, + lastSyncedJustNow: l10n.trayLastSyncedJustNow, + lastSyncedMinutesAgo: l10n.trayLastSyncedMinutesAgo, + lastSyncedHoursAgo: l10n.trayLastSyncedHoursAgo, + lastSyncedDaysAgo: l10n.trayLastSyncedDaysAgo, + ), + ); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_configure(settings)); + }); + return widget.child; + } + + Future _configure(AppSettings settings) async { + if (_configurationRunning) { + _configurationPending = true; + return; + } + _configurationRunning = true; + try { + final needsTray = + settings.showTrayIcon || + settings.runInBackgroundWhenClosed || + settings.startMinimizedToTray || + widget.startMinimizedAtLaunch; + final window = ref.read(desktopWindowServiceProvider); + if (!needsTray) { + await window.setHideOnClose(false); + await _tray?.stop(); + ref.read(desktopTrayDiagnosticProvider.notifier).state = null; + _refreshTimer?.cancel(); + _refreshTimer = null; + return; + } + final tray = _tray ??= WindowsTrayService( + loadPresentation: () async { + final presentation = await ref + .read(trayPresentationServiceProvider) + .load(); + return _formatter!.format(presentation); + }, + onCommand: _handleCommand, + onUnavailable: (errorCode) async { + ref.read(desktopTrayDiagnosticProvider.notifier).state = + errorCode ?? 'tray-unavailable'; + // Explorer recovery and native tray failures must never strand an + // otherwise healthy process with no reachable window. + await window.setHideOnClose(false); + await window.showWindow(); + }, + ); + var available = tray.isAvailable || await tray.start(); + if (available) { + await tray.refresh(); + available = tray.isAvailable; + } + await window.setHideOnClose( + shouldWindowsHideOnClose( + runInBackgroundWhenClosed: settings.runInBackgroundWhenClosed, + trayAvailable: available, + ), + ); + if (!available) { + // Never strand a process without either a visible window or tray. + await window.showWindow(); + } else { + ref.read(desktopTrayDiagnosticProvider.notifier).state = null; + } + _refreshTimer ??= Timer.periodic(const Duration(minutes: 1), (_) { + if (mounted) { + unawaited(_configure(ref.read(appSettingsControllerProvider))); + } + }); + if (!_startMinimizedHandled && + (widget.startMinimizedAtLaunch || settings.startMinimizedToTray)) { + _startMinimizedHandled = true; + if (available) { + await window.hideWindow(); + } else { + await window.showWindow(); + } + } + } finally { + _configurationRunning = false; + if (_configurationPending && mounted) { + _configurationPending = false; + unawaited(_configure(ref.read(appSettingsControllerProvider))); + } + } + } + + Future _handleCommand(WindowsTrayCommand command) async { + final window = ref.read(desktopWindowServiceProvider); + switch (command) { + case WindowsTrayCommand.open: + await window.showWindow(); + case WindowsTrayCommand.newEvent: + await window.showWindow(); + ref + .read(desktopNavigationServiceProvider) + .open(DesktopNavigationDestination.schedule); + await WidgetsBinding.instance.endOfFrame; + final context = this.context; + if (context.mounted) { + await showWindowsEventEditorDialog(context, ref); + } + case WindowsTrayCommand.newTask: + await window.showWindow(); + ref + .read(desktopNavigationServiceProvider) + .open(DesktopNavigationDestination.tasks); + await WidgetsBinding.instance.endOfFrame; + final context = this.context; + if (context.mounted) await showWindowsTaskEditorDialog(context, ref); + case WindowsTrayCommand.today: + await window.showWindow(); + ref + .read(desktopNavigationServiceProvider) + .open(DesktopNavigationDestination.schedule); + case WindowsTrayCommand.synchronize: + await ref.read(allAccountsSyncRunnerProvider)(); + await _tray?.refresh(); + case WindowsTrayCommand.settings: + await window.showWindow(); + ref + .read(desktopNavigationServiceProvider) + .open(DesktopNavigationDestination.settings); + case WindowsTrayCommand.quit: + ref.read(notificationSchedulerProvider).stop(); + ref.read(syncSchedulerProvider).stop(); + try { + await _tray?.stop(); + } on Object { + // Continue deterministic shutdown even if Explorer is unavailable. + } + try { + await ref.read(desktopNotificationBackendProvider).close(); + } on Object { + // The package notification host may already be disconnected. + } + try { + await ref.read(databaseProvider).close(); + } on Object { + // Native quit remains the final step so no invisible process lingers. + } + await window.quitApp(); + } + } +} diff --git a/lib/src/ui/windows/windows_diagnostics_dialog.dart b/lib/src/ui/windows/windows_diagnostics_dialog.dart new file mode 100644 index 00000000..6190b4e3 --- /dev/null +++ b/lib/src/ui/windows/windows_diagnostics_dialog.dart @@ -0,0 +1,241 @@ +import 'dart:io'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../core/logging/redacting_logger.dart'; +import '../../db/app_database.dart'; +import '../../google_tasks/api/google_tasks_api_surface.dart'; +import '../../google_tasks/api/tasks_discovery_revision.dart'; +import '../../features/sync/pending_op_resolution_service.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; + +Future showWindowsDiagnosticsDialog(BuildContext context, WidgetRef ref) { + return showDialog( + context: context, + builder: (context) => const _WindowsDiagnosticsDialog(), + ); +} + +class _WindowsDiagnosticsDialog extends ConsumerWidget { + const _WindowsDiagnosticsDialog(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final methods = implementedGoogleTasksMethods.toList()..sort(); + final accountId = ref.watch(activeAccountProvider); + final database = ref.watch(databaseProvider); + final settings = ref.watch(appSettingsControllerProvider); + final resolutionService = ref.watch(pendingOpResolutionServiceProvider); + final timeZone = ref.watch(localTimeZoneProvider); + final timeZoneDiagnostic = ref + .watch(localTimeZoneSourceProvider) + .diagnostic; + final notificationReadiness = ref.watch( + desktopNotificationReadinessProvider, + ); + final trayDiagnostic = ref.watch(desktopTrayDiagnosticProvider); + return ContentDialog( + title: Text(l10n.diagnostics), + constraints: const BoxConstraints(maxWidth: 760, maxHeight: 700), + content: ListView( + children: [ + SelectableText(Platform.operatingSystemVersion), + SelectableText(timeZone), + if (timeZoneDiagnostic != null) SelectableText(timeZoneDiagnostic), + SelectableText( + 'windows-notifications/${notificationReadiness.state.name}', + ), + if (notificationReadiness.diagnosticCode case final code?) + SelectableText(code), + if (trayDiagnostic != null) + SelectableText('windows-tray/$trayDiagnostic'), + const SizedBox(height: 16), + Text( + l10n.googleTasksApi, + style: FluentTheme.of(context).typography.subtitle, + ), + const SizedBox(height: 4), + SelectableText(l10n.discoveryRevision(googleTasksDiscoveryRevision)), + const SizedBox(height: 12), + Expander( + header: Text(l10n.implementedMethods), + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final method in methods) + ListTile( + leading: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.diagnostics), + ), + title: SelectableText(method), + subtitle: Text( + method.endsWith('.list') || method.endsWith('.get') + ? l10n.supportsTasksScopes + : l10n.requiresTasksScope, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Text( + l10n.blockedPendingOperations, + style: FluentTheme.of(context).typography.subtitle, + ), + const SizedBox(height: 8), + if (accountId == null) + Text(l10n.signInToInspectPendingOperations) + else + StreamBuilder>( + stream: database.pendingOpsDao.watchBlockedOps(accountId), + builder: (context, snapshot) { + final operations = snapshot.data ?? const []; + if (operations.isEmpty) { + return Text(l10n.noBlockedPendingOperations); + } + return Column( + children: [ + for (final operation in operations) + _PendingOperationTile( + operation: operation, + resolutionService: resolutionService, + redactDetails: settings.redactTaskContentInDiagnostics, + ), + ], + ); + }, + ), + ], + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + ); + } +} + +class _PendingOperationTile extends StatelessWidget { + const _PendingOperationTile({ + required this.operation, + required this.resolutionService, + required this.redactDetails, + }); + + final PendingOp operation; + final PendingOpResolutionService? resolutionService; + final bool redactDetails; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final error = [ + if (operation.lastErrorCode != null) operation.lastErrorCode, + if (operation.lastErrorMessage != null) + redactDetails + ? redactForLog(operation.lastErrorMessage) + : operation.lastErrorMessage, + ].join(' - '); + return ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.warning)), + title: Text('${operation.entityType}: ${operation.operation}'), + subtitle: Text( + [ + if (operation.taskListId != null) + l10n.pendingOpListId(operation.taskListId!), + if (operation.taskId != null) l10n.pendingOpTaskId(operation.taskId!), + l10n.pendingOpAttempts(operation.attemptCount), + if (error.isNotEmpty) error, + ].join('\n'), + ), + trailing: Tooltip( + message: l10n.operationActions, + child: DropDownButton( + title: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.more)), + items: [ + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.refresh)), + text: Text(l10n.retry), + onPressed: () => _retry(context), + ), + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.delete)), + text: Text(l10n.discard), + onPressed: () => _discard(context), + ), + ], + ), + ), + ); + } + + Future _retry(BuildContext context) async { + try { + final service = resolutionService; + if (service == null) return; + await service.retryNow(operation.id); + if (context.mounted) { + await _showResult(context, AppLocalizations.of(context).retryCompleted); + } + } on Object catch (error) { + if (context.mounted) await _showResult(context, redactForLog('$error')); + } + } + + Future _discard(BuildContext context) async { + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(l10n.discardPendingOperation), + content: Text(l10n.discardPendingOperationConfirmation), + actions: [ + Button( + onPressed: () => Navigator.pop(context, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.discard), + ), + ], + ), + ); + if (!(confirmed ?? false)) return; + try { + final service = resolutionService; + if (service == null) return; + await service.discard(operation.id); + if (context.mounted) { + await _showResult( + context, + AppLocalizations.of(context).pendingOperationDiscarded, + ); + } + } on Object catch (error) { + if (context.mounted) await _showResult(context, redactForLog('$error')); + } + } + + Future _showResult(BuildContext context, String message) { + return showDialog( + context: context, + builder: (context) => ContentDialog( + content: Text(message), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context).close), + ), + ], + ), + ); + } +} diff --git a/lib/src/ui/windows/windows_event_editor_dialog.dart b/lib/src/ui/windows/windows_event_editor_dialog.dart new file mode 100644 index 00000000..4f5b1510 --- /dev/null +++ b/lib/src/ui/windows/windows_event_editor_dialog.dart @@ -0,0 +1,1338 @@ +import 'package:fluent_ui/fluent_ui.dart'; +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 '../../features/accounts/data/accounts_repository.dart'; +import '../../features/calendar/data/calendar_repository.dart'; +import '../../features/calendar/domain/event_move_policy.dart'; +import '../../features/calendar/presentation/event_editor_draft.dart'; +import '../../features/recurrence/domain/event_recurrence_codec.dart'; +import '../../features/recurrence/domain/recurrence_rule.dart'; +import '../../providers/busy_provider.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; +import 'windows_event_reminders.dart'; +import 'windows_guest_update_dialog.dart'; +import 'windows_recurrence_dialog.dart'; +import 'windows_time_zone_dialog.dart'; + +Future showWindowsEventEditorDialog( + BuildContext context, + WidgetRef ref, { + String? eventId, + DateTime? initialStart, +}) async { + final repository = ref.read(calendarRepositoryProvider); + final detail = eventId == null + ? null + : await repository.loadEventDetail(eventId); + if (eventId != null && detail == null) return false; + final originalDraft = detail == null + ? null + : EventEditorDraft.fromEventDetail(detail); + final accounts = await ref + .read(accountsRepositoryProvider) + .watchAccounts() + .first; + final sources = writableCalendarSources( + await ref + .read(calendarRepositoryProvider) + .watchSourcesForAccounts(accounts.map((account) => account.id).toList()) + .first, + ); + if (!context.mounted) return false; + if (sources.isEmpty) { + await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(AppLocalizations.of(context).newEvent), + content: Text(AppLocalizations.of(context).noCalendarsSynced), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context).close), + ), + ], + ), + ); + return false; + } + + final title = TextEditingController(text: originalDraft?.title); + final description = TextEditingController(text: originalDraft?.description); + final location = TextEditingController(text: originalDraft?.location); + final guestEmail = TextEditingController(); + final categories = TextEditingController( + text: originalDraft?.categories.join(', '), + ); + var selectedSource = sources.firstWhere( + (source) => + source.id == originalDraft?.sourceId && + source.accountId == originalDraft?.accountId, + orElse: () => sources.first, + ); + final requestedStart = initialStart == null + ? null + : DateTime( + initialStart.year, + initialStart.month, + initialStart.day, + initialStart.hour == 0 && initialStart.minute == 0 + ? 9 + : initialStart.hour, + initialStart.minute, + ); + var start = + originalDraft?.start ?? + requestedStart ?? + DateTime.now().add(const Duration(hours: 1)); + start = DateTime( + start.year, + start.month, + start.day, + start.hour, + start.minute, + ); + var end = originalDraft?.end ?? start.add(const Duration(hours: 1)); + var allDay = originalDraft?.allDay ?? false; + var selectedTimeZone = + selectedSource.timeZone ?? ref.read(localTimeZoneProvider) ?? 'Etc/UTC'; + var recurrence = EventRecurrenceCodec.decode( + selectedSource.provider, + originalDraft?.recurrence, + baseDate: start, + ); + var recurrenceChanged = false; + var reminderMinutes = decodeWindowsEventReminderMinutes( + originalDraft?.reminders, + ); + var remindersChanged = false; + var attendees = [...?originalDraft?.attendees]; + var attendeesChanged = false; + var categoriesChanged = false; + var onlineMeeting = + originalDraft?.conference != null || + (originalDraft?.createConference ?? false); + var responseRequested = originalDraft?.responseRequested ?? true; + var hideAttendees = originalDraft?.hideAttendees ?? false; + var allowNewTimeProposals = originalDraft?.allowNewTimeProposals ?? true; + var importance = originalDraft?.importance ?? 'normal'; + var showAs = _showAsForProvider( + originalDraft?.showAs, + selectedSource.provider, + ); + var visibility = _visibilityForProvider( + originalDraft?.visibilityOrSensitivity, + selectedSource.provider, + ); + final initialSourceAccountId = selectedSource.accountId; + final initialSourceId = selectedSource.id; + final initialTitle = title.text; + final initialDescription = description.text; + final initialLocation = location.text; + final initialCategories = categories.text; + final initialStartValue = start; + final initialEndValue = end; + final initialAllDay = allDay; + final initialTimeZone = selectedTimeZone; + final initialOnlineMeeting = onlineMeeting; + final initialResponseRequested = responseRequested; + final initialHideAttendees = hideAttendees; + final initialAllowNewTimeProposals = allowNewTimeProposals; + final initialImportance = importance; + final initialShowAs = showAs; + final initialVisibility = visibility; + String? guestError; + var saving = false; + var saved = false; + var allowPop = false; + String? error; + bool hasPendingEdits() => + selectedSource.accountId != initialSourceAccountId || + selectedSource.id != initialSourceId || + title.text != initialTitle || + description.text != initialDescription || + location.text != initialLocation || + categories.text != initialCategories || + start != initialStartValue || + end != initialEndValue || + allDay != initialAllDay || + selectedTimeZone != initialTimeZone || + recurrenceChanged || + remindersChanged || + attendeesChanged || + categoriesChanged || + onlineMeeting != initialOnlineMeeting || + responseRequested != initialResponseRequested || + hideAttendees != initialHideAttendees || + allowNewTimeProposals != initialAllowNewTimeProposals || + importance != initialImportance || + showAs != initialShowAs || + visibility != initialVisibility; + + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + final validEnd = allDay + ? DateTime( + end.year, + end.month, + end.day, + ).isAfter(DateTime(start.year, start.month, start.day)) + : end.isAfter(start); + void closeDialog() { + setState(() => allowPop = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (dialogContext.mounted) Navigator.pop(dialogContext); + }); + } + + return PopScope( + canPop: allowPop || (!saving && !hasPendingEdits()), + onPopInvokedWithResult: (didPop, _) async { + if (didPop || saving || allowPop) return; + if (await _confirmDiscardEventChanges(context) && context.mounted) { + closeDialog(); + } + }, + child: ContentDialog( + title: Text(detail == null ? l10n.newEvent : l10n.editEvent), + constraints: const BoxConstraints(maxWidth: 560, maxHeight: 760), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InfoLabel( + label: l10n.title, + child: TextBox( + controller: title, + autofocus: true, + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.calendar, + child: ComboBox( + isExpanded: true, + value: selectedSource, + items: [ + for (final source in sources) + ComboBoxItem( + value: source, + child: Text(source.summary), + ), + ], + onChanged: saving + ? null + : (source) { + if (source != null) { + setState(() { + selectedSource = source; + selectedTimeZone = + source.timeZone ?? + ref.read(localTimeZoneProvider) ?? + 'Etc/UTC'; + recurrence = const RecurrenceRule.none(); + reminderMinutes = const []; + recurrenceChanged = true; + remindersChanged = true; + showAs = _showAsForProvider( + null, + source.provider, + ); + visibility = _visibilityForProvider( + null, + source.provider, + ); + importance = 'normal'; + onlineMeeting = false; + if (source.provider == BusyProvider.google) { + categories.clear(); + categoriesChanged = true; + } + }); + } + }, + ), + ), + const SizedBox(height: 12), + ToggleSwitch( + checked: allDay, + onChanged: saving + ? null + : (value) => setState(() => allDay = value), + content: Text(l10n.allDay), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.startDateTime, + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + DatePicker( + selected: start, + onChanged: saving + ? null + : (date) => setState(() { + final duration = end.difference(start); + start = DateTime( + date.year, + date.month, + date.day, + start.hour, + start.minute, + ); + end = start.add(duration); + }), + ), + if (!allDay) + TimePicker( + selected: start, + onChanged: saving + ? null + : (time) => setState(() { + final duration = end.difference(start); + start = DateTime( + start.year, + start.month, + start.day, + time.hour, + time.minute, + ); + end = start.add(duration); + }), + ), + ], + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.endDateTime, + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + DatePicker( + selected: end, + onChanged: saving + ? null + : (date) => setState(() { + end = DateTime( + date.year, + date.month, + date.day, + end.hour, + end.minute, + ); + }), + ), + if (!allDay) + TimePicker( + selected: end, + onChanged: saving + ? null + : (time) => setState(() { + end = DateTime( + end.year, + end.month, + end.day, + time.hour, + time.minute, + ); + }), + ), + ], + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.selectTimeZone, + child: Button( + onPressed: saving + ? null + : () async { + final result = await showWindowsTimeZoneDialog( + context, + selectedTimeZone: selectedTimeZone, + ); + if (result != null) { + setState(() => selectedTimeZone = result); + } + }, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text(selectedTimeZone), + ), + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.reminder, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for ( + var index = 0; + index < reminderMinutes.length; + index += 1 + ) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded( + child: ComboBox( + isExpanded: true, + value: reminderMinutes[index], + items: [ + for (final minutes + in windowsEventReminderValuesFor( + reminderMinutes[index], + )) + ComboBoxItem( + value: minutes, + child: Text( + _reminderLabel(l10n, minutes), + ), + ), + ], + onChanged: saving + ? null + : (value) { + if (value == null) return; + setState(() { + reminderMinutes = + normalizeWindowsEventReminderMinutes( + [ + ...reminderMinutes.take( + index, + ), + value, + ...reminderMinutes.skip( + index + 1, + ), + ], + ); + remindersChanged = true; + }); + }, + ), + ), + const SizedBox(width: 8), + IconButton( + icon: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.delete), + ), + onPressed: saving + ? null + : () => setState(() { + reminderMinutes = [ + ...reminderMinutes.take(index), + ...reminderMinutes.skip(index + 1), + ]; + remindersChanged = true; + }), + ), + ], + ), + ), + if (reminderMinutes.isEmpty || + (selectedSource.provider != + BusyProvider.microsoft && + reminderMinutes.length < + windowsEventReminderMinuteOptions.length)) + Button( + onPressed: saving + ? null + : () => setState(() { + reminderMinutes = [ + ...reminderMinutes, + nextWindowsEventReminderMinute( + reminderMinutes, + ), + ]; + remindersChanged = true; + }), + child: Text(l10n.addReminder), + ), + ], + ), + ), + 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, + 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), + InfoLabel( + label: l10n.availabilityShowAs, + child: ComboBox( + isExpanded: true, + value: showAs, + items: [ + for (final value in _showAsValues( + selectedSource.provider, + )) + ComboBoxItem( + value: value, + child: Text(_availabilityLabel(l10n, value)), + ), + ], + onChanged: saving + ? null + : (value) { + if (value != null) setState(() => showAs = value); + }, + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.visibility, + child: ComboBox( + isExpanded: true, + value: visibility, + items: [ + for (final value in _visibilityValues( + selectedSource.provider, + )) + ComboBoxItem( + value: value, + child: Text(_visibilityLabel(l10n, value)), + ), + ], + onChanged: saving + ? null + : (value) { + if (value != null) { + setState(() => visibility = value); + } + }, + ), + ), + if (selectedSource.provider == BusyProvider.microsoft) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.importance, + child: ComboBox( + isExpanded: true, + value: importance, + items: [ + ComboBoxItem( + value: 'low', + child: Text(l10n.importanceLow), + ), + ComboBoxItem( + value: 'normal', + child: Text(l10n.importanceNormal), + ), + ComboBoxItem( + value: 'high', + child: Text(l10n.importanceHigh), + ), + ], + onChanged: saving + ? null + : (value) { + if (value != null) { + setState(() => importance = value); + } + }, + ), + ), + ], + if (selectedSource.provider != BusyProvider.google) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.categories, + child: TextBox( + controller: categories, + enabled: !saving, + onChanged: (_) => + setState(() => categoriesChanged = true), + ), + ), + ], + if (selectedSource.provider == BusyProvider.google || + selectedSource.provider == BusyProvider.microsoft) ...[ + const SizedBox(height: 16), + ToggleSwitch( + checked: onlineMeeting, + onChanged: saving + ? null + : (value) => setState(() => onlineMeeting = value), + content: Text( + onlineMeeting && originalDraft?.conference != null + ? l10n.onlineMeetingAdded + : selectedSource.provider == BusyProvider.google + ? l10n.addGoogleMeet + : l10n.addTeamsMeeting, + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.guests, + child: Column( + children: [ + for (final attendee in attendees) + _WindowsAttendeeRow( + attendee: attendee, + requiredLabel: l10n.attendeeRequired, + optionalLabel: l10n.attendeeOptional, + canEdit: + !saving && + !attendee.self && + !attendee.organizer && + (originalDraft?.canManageAttendees ?? true), + onOptionalChanged: (optional) => setState(() { + 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, + ) + else + item, + ]; + attendeesChanged = true; + }), + onDelete: () => setState(() { + attendees = [ + for (final item in attendees) + if (!identical(item, attendee)) item, + ]; + attendeesChanged = true; + }), + ), + if (originalDraft?.canManageAttendees ?? true) + _WindowsAddGuestRow( + controller: guestEmail, + label: l10n.addGuest, + enabled: !saving, + onChanged: () => + setState(() => guestError = null), + onAdd: () { + final email = guestEmail.text.trim(); + if (!_looksLikeEmail(email)) { + setState( + () => + guestError = l10n.feedbackInvalidEmail, + ); + return; + } + if (!attendees.any( + (item) => + item.email.toLowerCase() == + email.toLowerCase(), + )) { + setState(() { + attendees = [ + ...attendees, + EventAttendeeDraft(email: email), + ]; + attendeesChanged = true; + }); + } + guestEmail.clear(); + }, + ), + if (guestError != null) + InfoBar( + title: Text(guestError!), + severity: InfoBarSeverity.warning, + ), + ], + ), + ), + const SizedBox(height: 8), + ToggleSwitch( + checked: responseRequested, + onChanged: saving + ? null + : (value) => + setState(() => responseRequested = value), + content: Text(l10n.requestResponses), + ), + const SizedBox(height: 8), + ToggleSwitch( + checked: hideAttendees, + onChanged: saving + ? null + : (value) => setState(() => hideAttendees = value), + content: Text(l10n.hideGuestList), + ), + const SizedBox(height: 8), + ToggleSwitch( + checked: allowNewTimeProposals, + onChanged: saving + ? null + : (value) => + setState(() => allowNewTimeProposals = value), + content: Text(l10n.allowNewTimeProposals), + ), + ], + const SizedBox(height: 12), + InfoLabel( + label: l10n.location, + child: TextBox(controller: location), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.description, + child: TextBox(controller: description, maxLines: 4), + ), + if (error != null) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text(error!), + severity: InfoBarSeverity.error, + ), + ], + ], + ), + ), + actions: [ + Button( + onPressed: saving + ? null + : () async { + if (hasPendingEdits() && + !await _confirmDiscardEventChanges(context)) { + return; + } + closeDialog(); + }, + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: saving || title.text.trim().isEmpty || !validEnd + ? null + : () async { + setState(() { + saving = true; + error = null; + }); + try { + final effectiveEnd = allDay + ? DateTime(end.year, end.month, end.day) + : end; + final effectiveStart = allDay + ? DateTime(start.year, start.month, start.day) + : start; + final encodedRecurrence = + recurrenceChanged && recurrence.repeats + ? EventRecurrenceCodec.encode( + selectedSource.provider, + recurrence, + baseDate: start, + allDay: allDay, + timeZone: selectedTimeZone, + ) + : null; + var draft = + (originalDraft ?? + EventEditorDraft.newEvent( + accountId: selectedSource.accountId, + sourceId: selectedSource.id, + providerCalendarId: + selectedSource.providerCalendarId, + start: effectiveStart, + end: effectiveEnd, + )) + .copyWith( + accountId: selectedSource.accountId, + sourceId: selectedSource.id, + providerCalendarId: + selectedSource.providerCalendarId, + title: title.text.trim(), + allDay: allDay, + start: effectiveStart, + end: effectiveEnd, + location: location.text.trim(), + description: description.text.trim(), + startTimeZone: selectedTimeZone, + endTimeZone: selectedTimeZone, + recurrence: recurrenceChanged + ? encodedRecurrence + : null, + recurrenceChanged: recurrenceChanged, + clearRecurrence: + recurrenceChanged && + !recurrence.repeats, + reminders: remindersChanged + ? encodeWindowsEventReminderPayload( + selectedSource.provider, + reminderMinutes, + ) + : null, + remindersChanged: remindersChanged, + attendees: attendeesChanged + ? attendees + : null, + attendeesChanged: attendeesChanged, + importance: + selectedSource.provider == + BusyProvider.microsoft + ? importance + : null, + showAs: showAs, + visibilityOrSensitivity: visibility, + categories: categoriesChanged + ? _categories(categories.text) + : null, + categoriesChanged: categoriesChanged, + createConference: + onlineMeeting && + originalDraft?.conference == null, + clearConference: !onlineMeeting, + responseRequested: responseRequested, + hideAttendees: hideAttendees, + allowNewTimeProposals: + allowNewTimeProposals, + ); + if (originalDraft?.providerRecurringEventId != null) { + final scope = + await _chooseRecurringEventMutationScope( + dialogContext, + selectedSource.provider, + ); + if (scope == null) { + if (dialogContext.mounted) { + setState(() => saving = false); + } + return; + } + draft = draft.copyWith( + recurringMutationScope: scope, + ); + } + + final moveSource = originalDraft == null + ? null + : sources.firstWhere( + (source) => + source.id == originalDraft.sourceId && + source.accountId == + originalDraft.accountId, + orElse: () => selectedSource, + ); + final moveStrategy = moveSource == null + ? CalendarEventMoveStrategy.none + : calendarEventMoveStrategy( + sourceAccountId: moveSource.accountId, + sourceId: moveSource.id, + sourceProviderCalendarId: + moveSource.providerCalendarId, + sourceProvider: moveSource.provider, + sourceDavCollectionId: + moveSource.davCollectionId, + destinationAccountId: + selectedSource.accountId, + destinationId: selectedSource.id, + destinationProviderCalendarId: + selectedSource.providerCalendarId, + destinationProvider: selectedSource.provider, + destinationDavCollectionId: + selectedSource.davCollectionId, + eventType: originalDraft?.eventType, + recurring: + originalDraft?.providerRecurringEventId != + null, + recurringScope: draft.recurringMutationScope, + ); + if (moveStrategy == + CalendarEventMoveStrategy.copyThenDelete) { + if (!dialogContext.mounted) return; + final confirmed = await _confirmCopyThenDelete( + dialogContext, + sourceLabel: _calendarSourceLabel( + moveSource!, + accounts, + ), + destinationLabel: _calendarSourceLabel( + selectedSource, + accounts, + ), + ); + if (!confirmed) { + if (dialogContext.mounted) { + setState(() => saving = false); + } + return; + } + } + + var guestUpdatePolicy = + CalendarGuestUpdatePolicy.send; + final guestProvider = + selectedSource.provider == BusyProvider.google || + selectedSource.provider == + BusyProvider.microsoft + ? selectedSource.provider + : moveSource?.provider; + if ((guestProvider == BusyProvider.google || + guestProvider == BusyProvider.microsoft) && + draft.isOrganizer == true && + _hasExternalGuests( + draft.attendees, + originalDraft?.attendees ?? const [], + )) { + if (!dialogContext.mounted) return; + final choice = await showWindowsGuestUpdateDialog( + dialogContext, + provider: guestProvider!, + action: WindowsGuestUpdateAction.save, + ); + if (choice == null) { + if (dialogContext.mounted) { + setState(() => saving = false); + } + return; + } + guestUpdatePolicy = choice; + } + if (detail == null) { + await repository.createLocalEvent( + draft, + guestUpdatePolicy: guestUpdatePolicy, + ); + } else { + await repository.updateLocalEvent( + draft, + guestUpdatePolicy: guestUpdatePolicy, + ); + } + saved = true; + closeDialog(); + } on Object catch (_) { + setState(() { + saving = false; + error = l10n.operationFailed; + }); + } + }, + child: saving + ? const SizedBox( + width: 16, + height: 16, + child: ProgressRing(), + ) + : Text(detail == null ? l10n.create : l10n.save), + ), + ], + ), + ); + }, + ), + ); + title.dispose(); + description.dispose(); + location.dispose(); + guestEmail.dispose(); + categories.dispose(); + return saved; +} + +class _WindowsAttendeeRow extends StatelessWidget { + const _WindowsAttendeeRow({ + required this.attendee, + required this.requiredLabel, + required this.optionalLabel, + required this.canEdit, + required this.onOptionalChanged, + required this.onDelete, + }); + + final EventAttendeeDraft attendee; + final String requiredLabel; + final String optionalLabel; + final bool canEdit; + final ValueChanged onOptionalChanged; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final compact = + constraints.maxWidth < 440 || + MediaQuery.textScalerOf(context).scale(1) > 1.3; + final identity = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(attendee.email), + if (attendee.displayName != null) + Text( + attendee.displayName!, + style: FluentTheme.of(context).typography.caption, + ), + ], + ); + final controls = canEdit + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + ComboBox( + value: attendee.optional, + items: [ + ComboBoxItem(value: false, child: Text(requiredLabel)), + ComboBoxItem(value: true, child: Text(optionalLabel)), + ], + onChanged: (value) { + if (value != null) onOptionalChanged(value); + }, + ), + IconButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.delete)), + onPressed: onDelete, + ), + ], + ) + : Text(attendee.optional ? optionalLabel : requiredLabel); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: compact + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + identity, + const SizedBox(height: 8), + Align( + alignment: AlignmentDirectional.centerEnd, + child: controls, + ), + ], + ) + : Row( + children: [ + Expanded(child: identity), + const SizedBox(width: 8), + controls, + ], + ), + ); + }, + ); +} + +class _WindowsAddGuestRow extends StatelessWidget { + const _WindowsAddGuestRow({ + required this.controller, + required this.label, + required this.enabled, + required this.onChanged, + required this.onAdd, + }); + + final TextEditingController controller; + final String label; + final bool enabled; + final VoidCallback onChanged; + final VoidCallback onAdd; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final compact = + constraints.maxWidth < 440 || + MediaQuery.textScalerOf(context).scale(1) > 1.3; + final field = TextBox( + controller: controller, + enabled: enabled, + placeholder: label, + onChanged: (_) => onChanged(), + onSubmitted: enabled ? (_) => onAdd() : null, + ); + final button = Button( + onPressed: enabled ? onAdd : null, + child: Text(label), + ); + return compact + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + field, + const SizedBox(height: 8), + Align(alignment: AlignmentDirectional.centerEnd, child: button), + ], + ) + : Row( + children: [ + Expanded(child: field), + const SizedBox(width: 8), + button, + ], + ); + }, + ); +} + +String _recurrenceLabel(AppLocalizations l10n, RecurrenceFrequency frequency) => + switch (frequency) { + RecurrenceFrequency.none => l10n.repeatNone, + RecurrenceFrequency.daily => l10n.repeatDaily, + RecurrenceFrequency.weekly => l10n.repeatWeekly, + RecurrenceFrequency.monthly => l10n.repeatMonthly, + RecurrenceFrequency.yearly => l10n.repeatYearly, + }; + +String _reminderLabel(AppLocalizations l10n, int minutes) { + if (minutes == 0) return l10n.reminderAtStart; + const minutesPerDay = Duration.minutesPerHour * Duration.hoursPerDay; + if (minutes % minutesPerDay == 0) { + return l10n.reminderDaysBefore(minutes ~/ minutesPerDay); + } + if (minutes % Duration.minutesPerHour == 0) { + return l10n.reminderHoursBefore(minutes ~/ Duration.minutesPerHour); + } + return l10n.reminderMinutesBefore(minutes); +} + +List _showAsValues(BusyProvider provider) => + provider == BusyProvider.microsoft + ? const ['free', 'tentative', 'busy', 'oof', 'workingElsewhere'] + : const ['opaque', 'transparent']; + +String _showAsForProvider(String? value, BusyProvider provider) { + if (provider == BusyProvider.microsoft) { + return switch (value) { + 'free' || 'tentative' || 'busy' || 'oof' || 'workingElsewhere' => value!, + 'transparent' => 'free', + _ => 'busy', + }; + } + return switch (value) { + 'opaque' || 'transparent' => value!, + 'free' => 'transparent', + _ => 'opaque', + }; +} + +String _availabilityLabel(AppLocalizations l10n, String value) => + switch (value) { + 'opaque' || 'busy' => l10n.busy, + 'transparent' || 'free' => l10n.availabilityFree, + 'tentative' => l10n.availabilityTentative, + 'oof' => l10n.availabilityOutOfOffice, + 'workingElsewhere' => l10n.availabilityWorkingElsewhere, + _ => value, + }; + +List _visibilityValues(BusyProvider provider) => + provider == BusyProvider.microsoft + ? const ['normal', 'personal', 'private', 'confidential'] + : const ['default', 'public', 'private', 'confidential']; + +String _visibilityForProvider(String? value, BusyProvider provider) { + if (provider == BusyProvider.microsoft) { + return switch (value) { + 'normal' || 'personal' || 'private' || 'confidential' => value!, + _ => 'normal', + }; + } + return switch (value) { + 'default' || 'public' || 'private' || 'confidential' => value!, + 'personal' => 'private', + _ => 'default', + }; +} + +String _visibilityLabel(AppLocalizations l10n, String value) => switch (value) { + 'default' => l10n.visibilityDefault, + 'public' => l10n.visibilityPublic, + 'private' => l10n.visibilityPrivate, + 'confidential' => l10n.visibilityConfidential, + 'normal' => l10n.sensitivityNormal, + 'personal' => l10n.sensitivityPersonal, + _ => value, +}; + +List _categories(String value) => value + .split(',') + .map((category) => category.trim()) + .where((category) => category.isNotEmpty) + .toSet() + .toList(growable: false); + +bool _looksLikeEmail(String value) => + RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(value); + +bool _hasExternalGuests( + List attendees, [ + List previous = const [], +]) => [ + ...attendees, + ...previous, +].any((attendee) => !attendee.self && !attendee.organizer); + +Future _confirmDiscardEventChanges(BuildContext context) async { + final l10n = AppLocalizations.of(context); + return await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.discardChanges), + content: const SizedBox.shrink(), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.discardChangesAction), + ), + ], + ), + ) ?? + false; +} + +Future _chooseRecurringEventMutationScope( + BuildContext context, + BusyProvider provider, +) { + final l10n = AppLocalizations.of(context); + final supportsFollowing = supportsThisAndFollowingEventMutation(provider); + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.recurringEventScope), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.chooseRecurringEventScope), + const SizedBox(height: 12), + Button( + onPressed: () => Navigator.pop( + dialogContext, + RecurringEventMutationScope.singleOccurrence, + ), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text(l10n.singleOccurrence), + ), + ), + const SizedBox(height: 8), + Button( + onPressed: supportsFollowing + ? () => Navigator.pop( + dialogContext, + RecurringEventMutationScope.thisAndFuture, + ) + : null, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + supportsFollowing + ? l10n.thisAndFollowingEvents + : '${l10n.thisAndFollowingEvents}\n' + '${l10n.thisAndFutureUnavailable}', + ), + ), + ), + const SizedBox(height: 8), + Button( + onPressed: () => Navigator.pop( + dialogContext, + RecurringEventMutationScope.entireSeries, + ), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text(l10n.entireSeries), + ), + ), + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + ], + ), + ); +} + +Future _confirmCopyThenDelete( + BuildContext context, { + required String sourceLabel, + required String destinationLabel, +}) async { + final l10n = AppLocalizations.of(context); + return await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.copyEventAndDeleteOriginal), + content: Text( + l10n.copyEventMoveWarning(sourceLabel, destinationLabel), + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.copyAndDelete), + ), + ], + ), + ) ?? + false; +} + +String _calendarSourceLabel( + CalendarSourceEntity source, + List accounts, +) { + for (final account in accounts) { + if (account.id == source.accountId) { + return '${account.selectorLabel} · ${source.summary}'; + } + } + return '${source.provider.displayName} · ${source.summary}'; +} diff --git a/lib/src/ui/windows/windows_event_reminders.dart b/lib/src/ui/windows/windows_event_reminders.dart new file mode 100644 index 00000000..2ff60712 --- /dev/null +++ b/lib/src/ui/windows/windows_event_reminders.dart @@ -0,0 +1,66 @@ +import '../../providers/busy_provider.dart'; + +const windowsEventReminderMinuteOptions = [0, 5, 10, 30, 60, 1440]; + +List decodeWindowsEventReminderMinutes(Object? reminders) { + if (reminders is! Map) return const []; + final direct = reminders['reminderMinutesBeforeStart']; + if (direct is int) return normalizeWindowsEventReminderMinutes([direct]); + final overrides = reminders['overrides']; + if (overrides is List) { + final result = []; + for (final value in overrides) { + if (value is Map && value['minutes'] is int) { + result.add(value['minutes'] as int); + } + } + return normalizeWindowsEventReminderMinutes(result); + } + final davMinutes = reminders['minutes']; + return davMinutes is List + ? normalizeWindowsEventReminderMinutes(davMinutes.whereType()) + : const []; +} + +List normalizeWindowsEventReminderMinutes(Iterable values) { + final result = []; + for (final value in values) { + if (value < 0 || result.contains(value)) continue; + result.add(value); + } + return result; +} + +Object encodeWindowsEventReminderPayload( + BusyProvider provider, + List minutes, +) { + final normalized = normalizeWindowsEventReminderMinutes(minutes); + if (provider == BusyProvider.microsoft) { + return normalized.isEmpty + ? const {'isReminderOn': false} + : { + 'isReminderOn': true, + 'reminderMinutesBeforeStart': normalized.first, + }; + } + return { + 'useDefault': false, + 'overrides': [ + for (final value in normalized) + {'method': 'popup', 'minutes': value}, + ], + }; +} + +List windowsEventReminderValuesFor(int selected) => + windowsEventReminderMinuteOptions.contains(selected) + ? windowsEventReminderMinuteOptions + : [...windowsEventReminderMinuteOptions, selected]; + +int nextWindowsEventReminderMinute(List existing) { + for (final value in windowsEventReminderMinuteOptions) { + if (!existing.contains(value)) return value; + } + return windowsEventReminderMinuteOptions.first; +} diff --git a/lib/src/ui/windows/windows_feedback_dialog.dart b/lib/src/ui/windows/windows_feedback_dialog.dart new file mode 100644 index 00000000..39ab5ca0 --- /dev/null +++ b/lib/src/ui/windows/windows_feedback_dialog.dart @@ -0,0 +1,285 @@ +import 'dart:io'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../features/connectivity/network_connectivity_service.dart'; +import '../../features/feedback/data/feedback_api_client.dart'; +import '../../features/feedback/data/feedback_submission.dart'; + +Future showWindowsFeedbackDialog(BuildContext context, WidgetRef ref) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => WindowsFeedbackDialog( + submissionService: ref.read(feedbackSubmissionServiceProvider), + ), + ); +} + +class WindowsFeedbackDialog extends StatefulWidget { + const WindowsFeedbackDialog({required this.submissionService, super.key}); + + final FeedbackSubmissionService submissionService; + + @override + State createState() => _WindowsFeedbackDialogState(); +} + +class _WindowsFeedbackDialogState extends State { + final _subject = TextEditingController(); + final _message = TextEditingController(); + final _replyEmail = TextEditingController(); + FeedbackCategory? _category; + var _includeTechnicalDetails = false; + var _submitting = false; + var _validationAttempted = false; + String? _status; + var _statusIsError = false; + var _submissionId = const Uuid().v4(); + + bool get _hasDraft => + _category != null || + _subject.text.trim().isNotEmpty || + _message.text.trim().isNotEmpty || + _replyEmail.text.trim().isNotEmpty || + _includeTechnicalDetails; + + @override + void dispose() { + _subject.dispose(); + _message.dispose(); + _replyEmail.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final validationMessage = _validationMessage(l10n); + return ContentDialog( + title: Text(l10n.reportAnIssue), + constraints: const BoxConstraints(maxWidth: 680, maxHeight: 760), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InfoLabel( + label: l10n.feedbackCategory, + child: ComboBox( + isExpanded: true, + value: _category, + placeholder: Text(l10n.feedbackSelectCategory), + items: [ + for (final category in FeedbackCategory.values) + ComboBoxItem( + value: category, + child: Text(_categoryLabel(l10n, category)), + ), + ], + onChanged: _submitting + ? null + : (value) => setState(() => _category = value), + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.feedbackSubject, + child: TextBox( + controller: _subject, + enabled: !_submitting, + onChanged: (_) => setState(() => _status = null), + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.feedbackDetailedMessage, + child: TextBox( + controller: _message, + enabled: !_submitting, + minLines: 4, + maxLines: 8, + onChanged: (_) => setState(() => _status = null), + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.feedbackReplyEmail, + child: TextBox( + controller: _replyEmail, + enabled: !_submitting, + onChanged: (_) => setState(() => _status = null), + ), + ), + const SizedBox(height: 16), + Checkbox( + checked: _includeTechnicalDetails, + onChanged: _submitting + ? null + : (value) => setState( + () => _includeTechnicalDetails = value ?? false, + ), + content: Text(l10n.feedbackIncludeTechnicalDetails), + ), + const SizedBox(height: 4), + Text( + l10n.feedbackTechnicalDetailsDisclosure, + style: FluentTheme.of(context).typography.caption, + ), + if (validationMessage != null) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text(validationMessage), + severity: InfoBarSeverity.warning, + ), + ], + if (_status != null) ...[ + const SizedBox(height: 12), + Semantics( + liveRegion: true, + child: InfoBar( + title: Text(_status!), + severity: _statusIsError + ? InfoBarSeverity.error + : InfoBarSeverity.success, + ), + ), + ], + ], + ), + ), + actions: [ + Button( + onPressed: _submitting ? null : _cancel, + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: _submitting ? null : _submit, + child: _submitting + ? const SizedBox(width: 16, height: 16, child: ProgressRing()) + : Text(l10n.feedbackSubmit), + ), + ], + ); + } + + String? _validationMessage(AppLocalizations l10n) { + if (!_validationAttempted) return null; + if (!FeedbackValidation.categoryIsValid(_category)) { + return l10n.feedbackCategoryRequired; + } + if (!FeedbackValidation.subjectIsValid(_subject.text)) { + return l10n.feedbackSubjectLengthError; + } + if (!FeedbackValidation.messageIsValid(_message.text)) { + return l10n.feedbackMessageLengthError; + } + if (!FeedbackValidation.replyEmailIsValid(_replyEmail.text)) { + return l10n.feedbackInvalidEmail; + } + return null; + } + + Future _cancel() async { + if (!_hasDraft) { + if (mounted) Navigator.pop(context); + return; + } + final l10n = AppLocalizations.of(context); + final discard = await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(l10n.discardChanges), + content: Text(l10n.discardChangesConfirmation), + actions: [ + Button( + onPressed: () => Navigator.pop(context, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.discardChangesAction), + ), + ], + ), + ); + if ((discard ?? false) && mounted) Navigator.pop(context); + } + + Future _submit() async { + setState(() { + _validationAttempted = true; + _status = null; + }); + final category = _category; + if (category == null || + _validationMessage(AppLocalizations.of(context)) != null) { + return; + } + setState(() => _submitting = true); + final locale = Localizations.localeOf(context).toLanguageTag(); + try { + final package = await PackageInfo.fromPlatform(); + final receipt = await widget.submissionService.submit( + FeedbackSubmission( + submissionId: _submissionId, + appVersion: package.version.isEmpty ? 'unknown' : package.version, + buildNumber: package.buildNumber.isEmpty ? '0' : package.buildNumber, + category: category, + subject: _subject.text, + message: _message.text, + replyEmail: _replyEmail.text, + platform: Platform.operatingSystem, + technicalDetails: _includeTechnicalDetails + ? FeedbackTechnicalDetails( + osVersion: Platform.operatingSystemVersion, + locale: locale, + ) + : null, + ), + ); + if (!mounted) return; + _subject.clear(); + _message.clear(); + _replyEmail.clear(); + setState(() { + _category = null; + _includeTechnicalDetails = false; + _validationAttempted = false; + _status = AppLocalizations.of(context).feedbackSuccess(receipt.id); + _statusIsError = false; + _submissionId = const Uuid().v4(); + }); + } on Object catch (error) { + if (!mounted) return; + setState(() { + _status = _failureMessage(AppLocalizations.of(context), error); + _statusIsError = true; + }); + } finally { + if (mounted) setState(() => _submitting = false); + } + } +} + +String _categoryLabel(AppLocalizations l10n, FeedbackCategory category) => + switch (category) { + FeedbackCategory.problem => l10n.feedbackCategoryProblem, + FeedbackCategory.feature => l10n.feedbackCategoryFeature, + FeedbackCategory.privacySecurity => l10n.feedbackCategoryPrivacySecurity, + FeedbackCategory.usability => l10n.feedbackCategoryUsability, + FeedbackCategory.other => l10n.feedbackCategoryOther, + }; + +String _failureMessage(AppLocalizations l10n, Object error) => switch (error) { + NetworkUnavailableException() || + FeedbackConnectionFailure() => l10n.feedbackConnectionError, + FeedbackTimeoutFailure() => l10n.feedbackTimeoutError, + FeedbackRateLimitedFailure() => l10n.feedbackRateLimitedError, + FeedbackRejectedFailure() => l10n.feedbackRejectedError, + _ => l10n.feedbackServerError, +}; diff --git a/lib/src/ui/windows/windows_guest_update_dialog.dart b/lib/src/ui/windows/windows_guest_update_dialog.dart new file mode 100644 index 00000000..f02d5593 --- /dev/null +++ b/lib/src/ui/windows/windows_guest_update_dialog.dart @@ -0,0 +1,74 @@ +import 'package:fluent_ui/fluent_ui.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../calendar_providers/calendar_mutation.dart'; +import '../../providers/busy_provider.dart'; + +enum WindowsGuestUpdateAction { save, delete } + +Future showWindowsGuestUpdateDialog( + BuildContext context, { + required BusyProvider provider, + required WindowsGuestUpdateAction action, +}) { + final l10n = AppLocalizations.of(context); + final saving = action == WindowsGuestUpdateAction.save; + if (provider == BusyProvider.microsoft) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text( + saving + ? l10n.microsoftNotifyGuestsSaveTitle + : l10n.microsoftNotifyGuestsDeleteTitle, + ), + content: Text( + saving + ? l10n.microsoftNotifyGuestsSaveMessage + : l10n.microsoftNotifyGuestsDeleteMessage, + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => + Navigator.pop(dialogContext, CalendarGuestUpdatePolicy.send), + child: Text(saving ? l10n.save : l10n.delete), + ), + ], + ), + ); + } + if (provider != BusyProvider.google) { + return Future.value(CalendarGuestUpdatePolicy.send); + } + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.notifyGuestsTitle), + content: Text( + saving ? l10n.notifyGuestsSaveMessage : l10n.notifyGuestsDeleteMessage, + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + Button( + onPressed: () => + Navigator.pop(dialogContext, CalendarGuestUpdatePolicy.doNotSend), + child: Text(l10n.doNotSend), + ), + FilledButton( + onPressed: () => + Navigator.pop(dialogContext, CalendarGuestUpdatePolicy.send), + child: Text(saving ? l10n.sendUpdates : l10n.sendCancellation), + ), + ], + ), + ); +} diff --git a/lib/src/ui/windows/windows_keyboard_shortcuts_dialog.dart b/lib/src/ui/windows/windows_keyboard_shortcuts_dialog.dart new file mode 100644 index 00000000..d06ac8e8 --- /dev/null +++ b/lib/src/ui/windows/windows_keyboard_shortcuts_dialog.dart @@ -0,0 +1,112 @@ +import 'package:fluent_ui/fluent_ui.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/busymax_shortcuts.dart'; + +Future showWindowsKeyboardShortcutsDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) { + final l10n = AppLocalizations.of(context); + return ContentDialog( + title: Text(l10n.keyboardShortcuts), + constraints: const BoxConstraints(maxWidth: 620, maxHeight: 640), + content: ListView( + shrinkWrap: true, + children: [ + _ShortcutGroup( + title: l10n.shortcutGroupGeneral, + entries: [ + ( + l10n.keyboardShortcuts, + BusyMaxShortcutLabels.keyboardShortcuts, + ), + (l10n.settings, BusyMaxShortcutLabels.settings), + (l10n.windowsSearch, BusyMaxShortcutLabels.search), + (l10n.showSidebar, BusyMaxShortcutLabels.sidebar), + ], + ), + _ShortcutGroup( + title: l10n.shortcutGroupNavigation, + entries: [ + ( + l10n.shortcutPreviousPeriod, + BusyMaxShortcutLabels.previousPeriod, + ), + (l10n.shortcutNextPeriod, BusyMaxShortcutLabels.nextPeriod), + (l10n.shortcutJumpToToday, BusyMaxShortcutLabels.today), + ], + ), + _ShortcutGroup( + title: l10n.shortcutGroupView, + entries: [ + (l10n.viewDay, BusyMaxShortcutLabels.dayView), + (l10n.viewWeek, BusyMaxShortcutLabels.weekView), + (l10n.viewMonth, BusyMaxShortcutLabels.monthView), + (l10n.viewYear, BusyMaxShortcutLabels.yearView), + (l10n.viewAgenda, BusyMaxShortcutLabels.agendaView), + ], + ), + _ShortcutGroup( + title: l10n.shortcutGroupCreateAndEdit, + entries: [ + (l10n.newEvent, BusyMaxShortcutLabels.newEvent), + (l10n.newTask, BusyMaxShortcutLabels.newTask), + (l10n.shortcutCancelEditing, BusyMaxShortcutLabels.dismiss), + ], + ), + ], + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + ); + }, + ); +} + +class _ShortcutGroup extends StatelessWidget { + const _ShortcutGroup({required this.title, required this.entries}); + + final String title; + final List<(String, String)> entries; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsetsDirectional.only(bottom: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(title, style: FluentTheme.of(context).typography.subtitle), + const SizedBox(height: 8), + for (final (label, shortcut) in entries) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Expanded(child: Text(label)), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + border: Border.all( + color: FluentTheme.of( + context, + ).resources.controlStrokeColorDefault, + ), + borderRadius: BorderRadius.circular(4), + ), + child: Text(shortcut), + ), + ], + ), + ), + ], + ), + ); +} diff --git a/lib/src/ui/windows/windows_recurrence_dialog.dart b/lib/src/ui/windows/windows_recurrence_dialog.dart new file mode 100644 index 00000000..8904de94 --- /dev/null +++ b/lib/src/ui/windows/windows_recurrence_dialog.dart @@ -0,0 +1,279 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../features/recurrence/domain/event_recurrence_codec.dart'; +import '../../features/recurrence/domain/recurrence_rule.dart'; + +enum _WindowsRecurrenceEnd { never, until, count } + +Future showWindowsRecurrenceDialog( + BuildContext context, { + required RecurrenceRule initial, + required DateTime baseDate, + required bool allDay, + required String timeZone, + required String providerLabel, + required RecurrenceRuleLimits limits, +}) { + var value = initial.isSupported ? initial : const RecurrenceRule.none(); + var end = value.count != null + ? _WindowsRecurrenceEnd.count + : value.untilRaw != null + ? _WindowsRecurrenceEnd.until + : _WindowsRecurrenceEnd.never; + var until = DateTime.tryParse(value.untilDateFor(timeZone: timeZone) ?? ''); + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + final valid = limits.supports(value); + return ContentDialog( + title: Text(l10n.repeat), + constraints: const BoxConstraints(maxWidth: 620, maxHeight: 680), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InfoLabel( + label: l10n.repeat, + child: ComboBox( + isExpanded: true, + value: value.frequency, + items: [ + for (final frequency in RecurrenceFrequency.values) + ComboBoxItem( + value: frequency, + child: Text(_frequencyLabel(l10n, frequency)), + ), + ], + onChanged: (frequency) { + if (frequency == null) return; + setState( + () => value = _ruleForFrequency(frequency, baseDate), + ); + }, + ), + ), + if (value.repeats) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.repeatEvery, + child: NumberBox( + value: value.interval, + min: 1, + max: 366, + mode: SpinButtonPlacementMode.inline, + onChanged: (interval) { + if (interval != null) { + setState( + () => value = value.copyWith(interval: interval), + ); + } + }, + ), + ), + ], + if (value.frequency == RecurrenceFrequency.weekly) ...[ + const SizedBox(height: 12), + Text( + l10n.repeatOn, + style: FluentTheme.of(context).typography.bodyStrong, + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final day in rfcWeekdays) + ToggleButton( + checked: value.byDay.contains(day), + onChanged: (checked) => setState(() { + final days = [...value.byDay]; + if (checked) { + if (!days.contains(day)) days.add(day); + } else if (days.length > 1) { + days.remove(day); + } + days.sort( + (a, b) => rfcWeekdays + .indexOf(a) + .compareTo(rfcWeekdays.indexOf(b)), + ); + value = value.copyWith(byDay: days); + }), + child: Text(_weekdayLabel(context, day)), + ), + ], + ), + ], + if (value.repeats) ...[ + const SizedBox(height: 16), + InfoLabel( + label: l10n.repeatEnd, + child: ComboBox<_WindowsRecurrenceEnd>( + isExpanded: true, + value: end, + items: [ + ComboBoxItem( + value: _WindowsRecurrenceEnd.never, + child: Text(l10n.repeatNever), + ), + ComboBoxItem( + value: _WindowsRecurrenceEnd.until, + child: Text(l10n.repeatUntil), + ), + ComboBoxItem( + value: _WindowsRecurrenceEnd.count, + child: Text(l10n.repeatAfter), + ), + ], + onChanged: (selection) { + if (selection == null) return; + setState(() { + end = selection; + value = switch (selection) { + _WindowsRecurrenceEnd.never => value.copyWith( + count: null, + untilRaw: null, + ), + _WindowsRecurrenceEnd.until => + value + .copyWith(count: null) + .withUntilDate( + _dateValue(until ?? baseDate), + allDay: allDay, + baseDate: baseDate, + timeZone: timeZone, + ), + _WindowsRecurrenceEnd.count => value.copyWith( + count: value.count ?? 10, + untilRaw: null, + ), + }; + }); + }, + ), + ), + if (end == _WindowsRecurrenceEnd.until) ...[ + const SizedBox(height: 12), + DatePicker( + selected: until ?? baseDate, + startDate: baseDate, + onChanged: (date) => setState(() { + until = date; + value = value.withUntilDate( + _dateValue(date), + allDay: allDay, + baseDate: baseDate, + timeZone: timeZone, + ); + }), + ), + ], + if (end == _WindowsRecurrenceEnd.count) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.repeatCount, + child: NumberBox( + value: value.count ?? 10, + min: 1, + max: 3500, + mode: SpinButtonPlacementMode.inline, + onChanged: (count) { + if (count != null) { + setState( + () => value = value.copyWith(count: count), + ); + } + }, + ), + ), + ], + ], + if (!valid) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text( + l10n.recurrenceUnsupportedByProvider(providerLabel), + ), + severity: InfoBarSeverity.warning, + ), + ], + ], + ), + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: valid + ? () => Navigator.pop(dialogContext, value) + : null, + child: Text(l10n.save), + ), + ], + ); + }, + ), + ); +} + +RecurrenceRule _ruleForFrequency( + RecurrenceFrequency frequency, + DateTime baseDate, +) { + if (frequency == RecurrenceFrequency.none) { + return const RecurrenceRule.none(); + } + return RecurrenceRule( + frequency: frequency, + interval: 1, + byDay: frequency == RecurrenceFrequency.weekly + ? [_rfcWeekday(baseDate.weekday)] + : const [], + byMonth: frequency == RecurrenceFrequency.yearly + ? [baseDate.month] + : const [], + byMonthDay: + frequency == RecurrenceFrequency.monthly || + frequency == RecurrenceFrequency.yearly + ? [baseDate.day] + : const [], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: const [], + exceptionDates: const [], + rawRules: const [], + isSupported: true, + ); +} + +String _frequencyLabel(AppLocalizations l10n, RecurrenceFrequency frequency) => + switch (frequency) { + RecurrenceFrequency.none => l10n.repeatNone, + RecurrenceFrequency.daily => l10n.repeatDaily, + RecurrenceFrequency.weekly => l10n.repeatWeekly, + RecurrenceFrequency.monthly => l10n.repeatMonthly, + RecurrenceFrequency.yearly => l10n.repeatYearly, + }; + +String _rfcWeekday(int weekday) => rfcWeekdays[weekday - 1]; + +String _weekdayLabel(BuildContext context, String weekday) { + final day = rfcWeekdays.indexOf(weekday) + 1; + final date = DateTime(2024, 1, day); + return DateFormat.E( + Localizations.localeOf(context).toLanguageTag(), + ).format(date); +} + +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/ui/windows/windows_schedule_page.dart b/lib/src/ui/windows/windows_schedule_page.dart new file mode 100644 index 00000000..65346f10 --- /dev/null +++ b/lib/src/ui/windows/windows_schedule_page.dart @@ -0,0 +1,1538 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../app/busymax_shortcuts.dart'; +import '../../app/common/busymax_design_values.dart'; +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/schedule/presentation/schedule_item_exporter.dart'; +import '../../features/task_lists/data/task_lists_repository.dart'; +import '../../schedule/schedule_filters.dart'; +import '../../schedule/schedule_item.dart'; +import '../../schedule/schedule_range.dart'; +import '../../schedule/schedule_source_visibility.dart'; +import '../../schedule/schedule_view_mode.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; +import 'windows_calendar_activation_flows.dart'; +import 'windows_event_editor_dialog.dart'; +import 'windows_guest_update_dialog.dart'; +import 'windows_schedule_source_pane.dart'; +import 'windows_task_details_dialog.dart'; +import 'windows_task_editor_dialog.dart'; + +class WindowsSchedulePage extends ConsumerStatefulWidget { + const WindowsSchedulePage({super.key}); + + @override + ConsumerState createState() => + _WindowsSchedulePageState(); +} + +class _WindowsSchedulePageState extends ConsumerState { + final _searchController = TextEditingController(); + final _searchFocusNode = FocusNode(); + var _selectedDate = DateTime.now(); + var _query = ''; + late ScheduleViewMode _mode; + var _agendaDays = 30; + var _agendaTaskLimit = 100; + var _refreshRevision = 0; + String? _taskListsKey; + Future>? _taskListsFuture; + String? _itemsKey; + Future>? _itemsFuture; + var _sourcePaneCollapsed = false; + + @override + void initState() { + super.initState(); + _mode = ref.read(appSettingsControllerProvider).scheduleViewMode; + } + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + void _reload() { + setState(() { + _refreshRevision += 1; + _taskListsKey = null; + _taskListsFuture = null; + _itemsKey = null; + _itemsFuture = null; + }); + } + + @override + Widget build(BuildContext context) { + final accountsState = ref.watch(accountsStreamProvider); + if (accountsState.isLoading && accountsState.valueOrNull == null) { + return const ScaffoldPage(content: Center(child: ProgressRing())); + } + if (accountsState.hasError && accountsState.valueOrNull == null) { + return ScaffoldPage( + content: Center( + child: InfoBar( + title: Text(AppLocalizations.of(context).scheduleUnavailable), + severity: InfoBarSeverity.error, + ), + ), + ); + } + final accounts = accountsState.valueOrNull ?? const []; + final accountIds = [for (final account in accounts) account.id]; + return StreamBuilder>( + stream: ref + .watch(calendarRepositoryProvider) + .watchSourcesForAccounts(accountIds), + builder: (context, sourcesSnapshot) { + if (sourcesSnapshot.connectionState == ConnectionState.waiting && + !sourcesSnapshot.hasData) { + return const ScaffoldPage(content: Center(child: ProgressRing())); + } + if (sourcesSnapshot.hasError && !sourcesSnapshot.hasData) { + return ScaffoldPage( + content: Center( + child: InfoBar( + title: Text(AppLocalizations.of(context).scheduleUnavailable), + severity: InfoBarSeverity.error, + ), + ), + ); + } + final sources = sourcesSnapshot.data ?? const []; + return FutureBuilder>( + future: _taskListsFor(accounts), + builder: (context, listsSnapshot) { + if (listsSnapshot.connectionState != ConnectionState.done && + !listsSnapshot.hasData) { + return const ScaffoldPage(content: Center(child: ProgressRing())); + } + final taskLists = listsSnapshot.data ?? const []; + final settings = ref.watch(appSettingsControllerProvider); + final visibility = ScheduleSourceVisibility.fromSources( + calendarSources: sources, + taskLists: taskLists, + settings: settings, + ); + return _buildPage( + context, + accounts: accounts, + sources: sources, + taskLists: taskLists, + visibility: visibility, + ); + }, + ); + }, + ); + } + + Widget _buildPage( + BuildContext context, { + required List accounts, + required List sources, + required List taskLists, + required ScheduleSourceVisibility visibility, + }) { + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + final range = _rangeForMode(); + return LayoutBuilder( + builder: (context, constraints) { + final canShowSourcePane = BusyMaxBreakpoints.showsSourcePane( + constraints.maxWidth, + ); + final showSourcePane = canShowSourcePane && !_sourcePaneCollapsed; + final sourcePane = WindowsScheduleSourcePane( + selectedDate: _selectedDate, + accounts: accounts, + calendarSources: sources, + taskLists: taskLists, + visibleCalendarSourceIds: visibility.visibleCalendarSourceIds, + visibleTaskListKeys: visibility.visibleTaskListKeys, + onDateSelected: _openDay, + onCalendarVisibilityChanged: _setCalendarVisible, + onTaskListVisibilityChanged: _setTaskListVisible, + onSourcesChanged: _reload, + ); + return CallbackShortcuts( + bindings: { + BusyMaxShortcutActivators.search: _focusSearch, + BusyMaxShortcutActivators.sidebar: () { + if (canShowSourcePane) { + setState(() => _sourcePaneCollapsed = !showSourcePane); + } else { + unawaited( + _showSourcesDialog( + accounts: accounts, + sources: sources, + taskLists: taskLists, + visibility: visibility, + ), + ); + } + }, + BusyMaxShortcutActivators.dismiss: _dismissSearch, + const SingleActivator( + LogicalKeyboardKey.arrowLeft, + shift: true, + ): () => + _invokeUnmodifiedShortcut(() => _movePeriod(-1)), + const SingleActivator( + LogicalKeyboardKey.arrowRight, + shift: true, + ): () => + _invokeUnmodifiedShortcut(() => _movePeriod(1)), + const SingleActivator(LogicalKeyboardKey.keyE): () => + _invokeUnmodifiedShortcut(() => unawaited(_createEvent())), + const SingleActivator(LogicalKeyboardKey.keyT): () => + _invokeUnmodifiedShortcut(() => unawaited(_createTask())), + const SingleActivator(LogicalKeyboardKey.keyT, shift: true): () => + _invokeUnmodifiedShortcut(() => _selectDate(DateTime.now())), + const SingleActivator(LogicalKeyboardKey.digit1): () => + _invokeUnmodifiedShortcut(() => _setMode(ScheduleViewMode.day)), + const SingleActivator(LogicalKeyboardKey.numpad1): () => + _invokeUnmodifiedShortcut(() => _setMode(ScheduleViewMode.day)), + const SingleActivator(LogicalKeyboardKey.digit2): () => + _invokeUnmodifiedShortcut( + () => _setMode(ScheduleViewMode.week), + ), + const SingleActivator(LogicalKeyboardKey.numpad2): () => + _invokeUnmodifiedShortcut( + () => _setMode(ScheduleViewMode.week), + ), + const SingleActivator(LogicalKeyboardKey.digit3): () => + _invokeUnmodifiedShortcut( + () => _setMode(ScheduleViewMode.month), + ), + const SingleActivator(LogicalKeyboardKey.numpad3): () => + _invokeUnmodifiedShortcut( + () => _setMode(ScheduleViewMode.month), + ), + const SingleActivator(LogicalKeyboardKey.digit4): () => + _invokeUnmodifiedShortcut( + () => _setMode(ScheduleViewMode.year), + ), + const SingleActivator(LogicalKeyboardKey.numpad4): () => + _invokeUnmodifiedShortcut( + () => _setMode(ScheduleViewMode.year), + ), + const SingleActivator(LogicalKeyboardKey.digit5): () => + _invokeUnmodifiedShortcut( + () => _setMode(ScheduleViewMode.agenda), + ), + const SingleActivator(LogicalKeyboardKey.numpad5): () => + _invokeUnmodifiedShortcut( + () => _setMode(ScheduleViewMode.agenda), + ), + }, + child: ScaffoldPage( + header: PageHeader( + title: Text(_periodTitle(l10n, locale, range)), + commandBar: CommandBar( + compactBreakpointWidth: BusyMaxBreakpoints.compact, + primaryItems: [ + if (!showSourcePane) + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.more)), + label: Text(l10n.showSidebar), + onPressed: () { + if (canShowSourcePane) { + setState(() => _sourcePaneCollapsed = false); + } else { + unawaited( + _showSourcesDialog( + accounts: accounts, + sources: sources, + taskLists: taskLists, + visibility: visibility, + ), + ); + } + }, + ), + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.today)), + label: Text(l10n.today), + onPressed: () => _selectDate(DateTime.now()), + ), + if (_mode != ScheduleViewMode.agenda) ...[ + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.previous)), + tooltip: l10n.shortcutPreviousPeriodDescription, + onPressed: () => _movePeriod(-1), + ), + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.next)), + tooltip: l10n.shortcutNextPeriodDescription, + onPressed: () => _movePeriod(1), + ), + ], + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.add)), + label: Text(l10n.newEvent), + onPressed: () => unawaited(_createEvent()), + ), + ], + secondaryItems: [ + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.task)), + label: Text(l10n.newTask), + onPressed: () => unawaited(_createTask()), + ), + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.refresh)), + label: Text(l10n.refresh), + onPressed: _reload, + ), + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.calendar)), + label: Text(l10n.importIcsFile), + onPressed: () => unawaited( + showWindowsIcsImportFlow( + context, + ref, + ).then((_) => _reload()), + ), + ), + ], + ), + ), + content: Row( + children: [ + if (showSourcePane) ...[ + SizedBox( + width: BusyMaxDimensions.sourcePaneWidth, + child: sourcePane, + ), + const Divider(direction: Axis.vertical), + ], + Expanded( + child: Column( + children: [ + Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + 20, + 0, + 20, + 12, + ), + child: Row( + children: [ + Expanded( + child: TextBox( + controller: _searchController, + focusNode: _searchFocusNode, + placeholder: l10n.windowsSearch, + prefix: Padding( + padding: const EdgeInsetsDirectional.only( + start: 10, + ), + child: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.search), + ), + ), + onChanged: (value) { + _query = value; + _reload(); + }, + ), + ), + const SizedBox(width: 12), + _ViewModeMenu(mode: _mode, onChanged: _setMode), + ], + ), + ), + Expanded( + child: FutureBuilder>( + future: _itemsFor( + range: range, + accounts: accounts, + visibility: visibility, + ), + builder: (context, snapshot) { + if (snapshot.connectionState != + ConnectionState.done) { + return const Center(child: ProgressRing()); + } + if (snapshot.hasError) { + return Center( + child: InfoBar( + title: Text(l10n.scheduleUnavailable), + severity: InfoBarSeverity.error, + action: Button( + onPressed: _reload, + child: Text(l10n.retry), + ), + ), + ); + } + final items = snapshot.data ?? const []; + if (items.isEmpty && + _mode != ScheduleViewMode.agenda) { + return _WindowsScheduleEmptyState( + searching: _query.isNotEmpty, + noVisibleSources: + visibility + .visibleCalendarSourceIds + .isEmpty && + visibility.visibleTaskListKeys.isEmpty, + ); + } + return _ScheduleModeView( + mode: _mode, + selectedDate: _selectedDate, + range: range, + items: items, + locale: locale, + onOpen: _showItemDetails, + onSelectDate: _mode == ScheduleViewMode.year + ? _openMonth + : _openDay, + onLoadMoreAgenda: _loadMoreAgenda, + ); + }, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } + + Future _createEvent() async { + final changed = await showWindowsEventEditorDialog( + context, + ref, + initialStart: _selectedDate, + ); + if (changed && mounted) _reload(); + } + + Future _createTask() async { + final changed = await showWindowsTaskEditorDialog(context, ref); + if (changed && mounted) _reload(); + } + + void _focusSearch() => _searchFocusNode.requestFocus(); + + void _dismissSearch() { + if (!_searchFocusNode.hasFocus && _query.isEmpty) return; + _searchController.clear(); + _query = ''; + _searchFocusNode.unfocus(); + _reload(); + } + + void _invokeUnmodifiedShortcut(VoidCallback callback) { + final focusContext = FocusManager.instance.primaryFocus?.context; + if (focusContext != null && + (focusContext.widget is EditableText || + focusContext.findAncestorWidgetOfExactType() != + null)) { + return; + } + callback(); + } + + Future> _taskListsFor(List accounts) { + final key = + accounts + .where((account) => account.isTaskCapable) + .map((account) => account.id) + .toList() + ..sort(); + final encodedKey = key.join('\u0000'); + if (_taskListsKey != encodedKey || _taskListsFuture == null) { + _taskListsKey = encodedKey; + _taskListsFuture = Future.wait([ + for (final accountId in key) + ref + .read(taskListsRepositoryForAccountProvider(accountId)) + .listTaskLists(), + ]).then((groups) => [for (final group in groups) ...group]); + } + return _taskListsFuture!; + } + + Future> _itemsFor({ + required ScheduleRange range, + required List accounts, + required ScheduleSourceVisibility visibility, + }) { + final calendarIds = visibility.visibleCalendarSourceIds.toList()..sort(); + final taskKeys = + visibility.visibleTaskListKeys + .map((key) => '${key.accountId}/${key.taskListId}') + .toList() + ..sort(); + final accountIds = [for (final account in accounts) account.id]..sort(); + final key = [ + _refreshRevision, + _mode.name, + range.start.toIso8601String(), + range.end.toIso8601String(), + _query, + accountIds.join(','), + calendarIds.join(','), + taskKeys.join(','), + ].join('|'); + if (_itemsKey == key && _itemsFuture != null) return _itemsFuture!; + _itemsKey = key; + final filters = ScheduleFilters( + query: _query, + accountIds: accountIds.toSet(), + sourceIds: visibility.visibleCalendarSourceIds, + taskListKeys: visibility.visibleTaskListKeys, + sourceFilterActive: true, + taskListFilterActive: true, + includeCalendarEvents: true, + includeTasks: true, + showCompletedTasks: true, + showNoDateTasks: _mode == ScheduleViewMode.agenda, + ); + _itemsFuture = _loadItems(range, filters); + return _itemsFuture!; + } + + Future> _loadItems( + ScheduleRange range, + ScheduleFilters filters, + ) async { + final repository = ref.read(scheduleRepositoryProvider); + final items = [ + ...await repository.listItems(range: range, filters: filters), + ]; + if (_mode == ScheduleViewMode.agenda && _query.trim().isEmpty) { + final overdue = await repository.listOverdueTasks( + before: range.start, + limit: _agendaTaskLimit, + filters: filters, + ); + final noDate = await repository.listNoDateTasks( + limit: _agendaTaskLimit, + filters: filters, + ); + items.addAll(overdue.items); + items.addAll(noDate.items); + } + final unique = {}; + for (final item in items) { + unique['${item.kind.name}/${item.accountId}/${item.sourceId}/${item.id}'] = + item; + } + final result = unique.values.toList() + ..sort((left, right) { + final leftStart = left.start; + final rightStart = right.start; + if (leftStart == null && rightStart != null) return 1; + if (leftStart != null && rightStart == null) return -1; + final dateOrder = leftStart?.compareTo(rightStart!) ?? 0; + return dateOrder != 0 + ? dateOrder + : left.title.toLowerCase().compareTo(right.title.toLowerCase()); + }); + return result; + } + + ScheduleRange _rangeForMode() => switch (_mode) { + ScheduleViewMode.day => ScheduleRange.day(_selectedDate), + ScheduleViewMode.week => ScheduleRange.week(_selectedDate), + ScheduleViewMode.month => ScheduleRange.month(_selectedDate), + ScheduleViewMode.year => ScheduleRange.year(_selectedDate), + ScheduleViewMode.agenda => ScheduleRange( + start: _dateOnly(DateTime.now()), + end: DateTime( + DateTime.now().year, + DateTime.now().month, + DateTime.now().day + _agendaDays, + ), + ), + }; + + String _periodTitle( + AppLocalizations l10n, + String locale, + ScheduleRange range, + ) => switch (_mode) { + ScheduleViewMode.day => DateFormat.yMMMMEEEEd(locale).format(_selectedDate), + ScheduleViewMode.week => + '${DateFormat.yMMMd(locale).format(range.start)} – ' + '${DateFormat.yMMMd(locale).format(range.end.subtract(const Duration(days: 1)))}', + ScheduleViewMode.month => DateFormat.yMMMM(locale).format(_selectedDate), + ScheduleViewMode.year => DateFormat.y(locale).format(_selectedDate), + ScheduleViewMode.agenda => l10n.viewAgenda, + }; + + void _selectDate(DateTime date) { + setState(() { + _selectedDate = _dateOnly(date); + _itemsKey = null; + }); + } + + void _openDay(DateTime date) { + _selectDate(date); + _setMode(ScheduleViewMode.day); + } + + void _openMonth(DateTime date) { + _selectDate(DateTime(date.year, date.month)); + _setMode(ScheduleViewMode.month); + } + + void _movePeriod(int direction) { + final next = switch (_mode) { + ScheduleViewMode.day => DateTime( + _selectedDate.year, + _selectedDate.month, + _selectedDate.day + direction, + ), + ScheduleViewMode.week => DateTime( + _selectedDate.year, + _selectedDate.month, + _selectedDate.day + (7 * direction), + ), + ScheduleViewMode.month => DateTime( + _selectedDate.year, + _selectedDate.month + direction, + 1, + ), + ScheduleViewMode.year => DateTime( + _selectedDate.year + direction, + _selectedDate.month, + 1, + ), + ScheduleViewMode.agenda => _selectedDate, + }; + _selectDate(next); + } + + void _setMode(ScheduleViewMode mode) { + if (_mode == mode) return; + setState(() { + _mode = mode; + _itemsKey = null; + }); + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setScheduleViewMode(mode), + ); + } + + void _loadMoreAgenda() { + setState(() { + _agendaDays += 30; + _agendaTaskLimit += 100; + _itemsKey = null; + }); + } + + void _setCalendarVisible(CalendarSourceEntity source, bool visible) { + unawaited( + ref + .read(calendarRepositoryProvider) + .setSourceSelected(source.id, visible) + .then((_) => _reload()), + ); + } + + void _setTaskListVisible(TaskListEntity list, bool visible) { + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setTaskListVisibleInSchedule( + accountId: list.accountId, + taskListId: list.id, + visible: visible, + ) + .then((_) => _reload()), + ); + } + + Future _showSourcesDialog({ + required List accounts, + required List sources, + required List taskLists, + required ScheduleSourceVisibility visibility, + }) { + final visibleCalendars = {...visibility.visibleCalendarSourceIds}; + final visibleTaskLists = {...visibility.visibleTaskListKeys}; + return showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setDialogState) => ContentDialog( + title: Text(AppLocalizations.of(context).showSidebar), + content: SizedBox( + width: 520, + height: math.min(MediaQuery.sizeOf(context).height - 180, 620), + child: WindowsScheduleSourcePane( + selectedDate: _selectedDate, + accounts: accounts, + calendarSources: sources, + taskLists: taskLists, + visibleCalendarSourceIds: visibleCalendars, + visibleTaskListKeys: visibleTaskLists, + onDateSelected: (date) { + Navigator.pop(dialogContext); + _openDay(date); + }, + onCalendarVisibilityChanged: (source, visible) { + setDialogState(() { + if (visible) { + visibleCalendars.add(source.id); + } else { + visibleCalendars.remove(source.id); + } + }); + _setCalendarVisible(source, visible); + }, + onTaskListVisibilityChanged: (list, visible) { + final key = ScheduleTaskListKey( + accountId: list.accountId, + taskListId: list.id, + ); + setDialogState(() { + if (visible) { + visibleTaskLists.add(key); + } else { + visibleTaskLists.remove(key); + } + }); + _setTaskListVisible(list, visible); + }, + onSourcesChanged: _reload, + ), + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(context).close), + ), + ], + ), + ), + ); + } + + Future _showItemDetails(ScheduleItem item) { + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + final time = _itemDateLabel(item, locale); + return showDialog( + context: context, + builder: (dialogContext) => ContentDialog( + title: Text(item.title), + content: Text( + [ + time, + ?item.sourceName, + ?item.accountDisplayName, + if (item case CalendarScheduleItem(:final location?)) location, + if (item case CalendarScheduleItem(:final description?)) + description, + if (item case TaskScheduleItem(:final notes?)) notes, + ].where((value) => value.trim().isNotEmpty).join('\n'), + ), + actions: [ + if (item.capabilities.canEdit) + Button( + onPressed: () { + Navigator.pop(dialogContext); + unawaited(_edit(item)); + }, + child: Text( + item is TaskScheduleItem ? l10n.editTask : l10n.editEvent, + ), + ), + if (item.capabilities.canDelete) + Button( + onPressed: () { + Navigator.pop(dialogContext); + unawaited(_delete(item)); + }, + child: Text(l10n.delete), + ), + Button( + onPressed: () { + Navigator.pop(dialogContext); + unawaited(_export(item)); + }, + child: Text(l10n.export), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.close), + ), + ], + ), + ); + } + + Future _edit(ScheduleItem item) async { + if (!mounted) return; + final currentContext = context; + final bool changed; + if (item is CalendarScheduleItem) { + changed = await showWindowsEventEditorDialog( + currentContext, + ref, + eventId: item.id, + ); + } else if (item is TaskScheduleItem) { + changed = await showWindowsTaskDetailsDialog(currentContext, ref, item); + } else { + return; + } + if (changed && mounted) _reload(); + } + + Future _export(ScheduleItem item) async { + final l10n = AppLocalizations.of(context); + try { + final file = await exportScheduleItemWithSaveDialog(item); + if (file == null || !mounted) return; + await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(l10n.export), + content: Text(l10n.exportedFile(file.path)), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + ), + ); + } on Object catch (_) { + if (!mounted) return; + await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(l10n.export), + content: InfoBar( + title: Text(l10n.exportFailed(l10n.operationFailed)), + severity: InfoBarSeverity.error, + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + ), + ); + } + } + + Future _delete(ScheduleItem item) async { + final l10n = AppLocalizations.of(context); + RecurringEventMutationScope? scope; + if (item is CalendarScheduleItem && item.providerRecurringEventId != null) { + scope = await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(l10n.deleteEvent), + content: Text(l10n.chooseRecurringEventScope), + actions: [ + Button( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + Button( + onPressed: () => Navigator.pop( + context, + RecurringEventMutationScope.singleOccurrence, + ), + child: Text(l10n.singleOccurrence), + ), + if (supportsThisAndFollowingEventMutation(item.provider)) + Button( + onPressed: () => Navigator.pop( + context, + RecurringEventMutationScope.thisAndFuture, + ), + child: Text(l10n.thisAndFollowingEvents), + ), + FilledButton( + onPressed: () => Navigator.pop( + context, + RecurringEventMutationScope.entireSeries, + ), + child: Text(l10n.entireSeries), + ), + ], + ), + ); + if (scope == null) return; + } + var guestUpdatePolicy = CalendarGuestUpdatePolicy.send; + final hasGuestDeliveryChoice = + item is CalendarScheduleItem && + item.isOrganizer == true && + _calendarItemHasExternalGuests(item); + if (hasGuestDeliveryChoice) { + if (!mounted) return; + final choice = await showWindowsGuestUpdateDialog( + context, + provider: item.provider, + action: WindowsGuestUpdateAction.delete, + ); + if (choice == null) return; + guestUpdatePolicy = choice; + } else if (scope == null) { + if (!mounted) return; + final confirmed = await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text( + item is TaskScheduleItem ? l10n.deleteTask : l10n.deleteEvent, + ), + content: Text(item.title), + actions: [ + Button( + onPressed: () => Navigator.pop(context, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.delete), + ), + ], + ), + ); + if (!(confirmed ?? false)) return; + } + try { + switch (item) { + case CalendarScheduleItem(): + await ref + .read(calendarRepositoryProvider) + .deleteLocalEvent( + item.id, + recurringScope: scope, + guestUpdatePolicy: guestUpdatePolicy, + ); + case TaskScheduleItem(): + await ref + .read(tasksRepositoryForAccountProvider(item.accountId)) + .deleteTask(item.sourceId, item.id); + } + if (mounted) _reload(); + } on Object catch (_) { + if (!mounted) return; + await showDialog( + context: context, + builder: (context) => ContentDialog( + content: InfoBar( + title: Text(l10n.operationFailed), + severity: InfoBarSeverity.error, + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + ), + ); + } + } +} + +bool _calendarItemHasExternalGuests(CalendarScheduleItem item) => + item.attendees.any( + (attendee) => attendee['self'] != true && attendee['organizer'] != true, + ); + +class _ViewModeMenu extends StatelessWidget { + const _ViewModeMenu({required this.mode, required this.onChanged}); + + final ScheduleViewMode mode; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropDownButton( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(windowsBusyMaxGlyph(_modeGlyph(mode))), + const SizedBox(width: 8), + Text(_modeLabel(AppLocalizations.of(context), mode)), + ], + ), + items: [ + for (final value in ScheduleViewMode.values) + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(_modeGlyph(value))), + text: Text(_modeLabel(AppLocalizations.of(context), value)), + onPressed: () => onChanged(value), + ), + ], + ); + } +} + +class _ScheduleModeView extends StatelessWidget { + const _ScheduleModeView({ + required this.mode, + required this.selectedDate, + required this.range, + required this.items, + required this.locale, + required this.onOpen, + required this.onSelectDate, + required this.onLoadMoreAgenda, + }); + + final ScheduleViewMode mode; + final DateTime selectedDate; + final ScheduleRange range; + final List items; + final String locale; + final ValueChanged onOpen; + final ValueChanged onSelectDate; + final VoidCallback onLoadMoreAgenda; + + @override + Widget build(BuildContext context) => switch (mode) { + ScheduleViewMode.day => _AgendaList( + items: items, + locale: locale, + onOpen: onOpen, + groupByDate: false, + ), + ScheduleViewMode.week => _WeekView( + range: range, + items: items, + locale: locale, + onOpen: onOpen, + onSelectDate: onSelectDate, + ), + ScheduleViewMode.month => _MonthView( + selectedDate: selectedDate, + range: range, + items: items, + locale: locale, + onOpen: onOpen, + onSelectDate: onSelectDate, + ), + ScheduleViewMode.year => _YearView( + selectedDate: selectedDate, + items: items, + locale: locale, + onSelectDate: onSelectDate, + ), + ScheduleViewMode.agenda => _AgendaList( + items: items, + locale: locale, + onOpen: onOpen, + groupByDate: true, + onLoadMore: onLoadMoreAgenda, + ), + }; +} + +class _AgendaList extends StatelessWidget { + const _AgendaList({ + required this.items, + required this.locale, + required this.onOpen, + required this.groupByDate, + this.onLoadMore, + }); + + final List items; + final String locale; + final ValueChanged onOpen; + final bool groupByDate; + final VoidCallback? onLoadMore; + + @override + Widget build(BuildContext context) { + final rows = []; + if (items.isEmpty) { + rows.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Center( + child: Text(AppLocalizations.of(context).noEventsOrTasks), + ), + ), + ); + } + DateTime? previousDay; + var noDateShown = false; + for (final item in items) { + final day = item.start == null ? null : _dateOnly(item.start!); + if (groupByDate && day != previousDay) { + rows.add( + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(4, 14, 4, 6), + child: Text( + day == null + ? AppLocalizations.of(context).noDate + : DateFormat.yMMMMEEEEd(locale).format(day), + style: FluentTheme.of(context).typography.subtitle, + ), + ), + ); + previousDay = day; + noDateShown = day == null; + } else if (groupByDate && day == null && !noDateShown) { + rows.add( + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(4, 14, 4, 6), + child: Text( + AppLocalizations.of(context).noDate, + style: FluentTheme.of(context).typography.subtitle, + ), + ), + ); + noDateShown = true; + } + rows.add( + _ScheduleItemCard( + item: item, + locale: locale, + onPressed: () => onOpen(item), + ), + ); + } + if (onLoadMore != null) { + rows.add( + Padding( + padding: const EdgeInsets.only(top: 12), + child: Button( + onPressed: onLoadMore, + child: Text(AppLocalizations.of(context).windowsAgendaLoadMore), + ), + ), + ); + } + return ListView( + padding: const EdgeInsetsDirectional.fromSTEB(20, 0, 20, 24), + children: rows, + ); + } +} + +class _WeekView extends StatelessWidget { + const _WeekView({ + required this.range, + required this.items, + required this.locale, + required this.onOpen, + required this.onSelectDate, + }); + + final ScheduleRange range; + final List items; + final String locale; + final ValueChanged onOpen; + final ValueChanged onSelectDate; + + @override + Widget build(BuildContext context) { + final days = [ + for (var offset = 0; offset < 7; offset += 1) + DateTime(range.start.year, range.start.month, range.start.day + offset), + ]; + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: math.max(constraints.maxWidth, 840), + height: constraints.maxHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final day in days) + Expanded( + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(3, 0, 3, 12), + child: Card( + child: Column( + children: [ + HyperlinkButton( + onPressed: () => onSelectDate(day), + child: Text(DateFormat.MMMEd(locale).format(day)), + ), + const Divider(), + Expanded( + child: ListView( + padding: const EdgeInsets.all(6), + children: [ + for (final item in items.where( + (item) => _itemOccursOn(item, day), + )) + _CompactScheduleItem( + item: item, + locale: locale, + onPressed: () => onOpen(item), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _MonthView extends StatelessWidget { + const _MonthView({ + required this.selectedDate, + required this.range, + required this.items, + required this.locale, + required this.onOpen, + required this.onSelectDate, + }); + + final DateTime selectedDate; + final ScheduleRange range; + final List items; + final String locale; + final ValueChanged onOpen; + final ValueChanged onSelectDate; + + @override + Widget build(BuildContext context) { + final days = []; + var cursor = range.start; + while (cursor.isBefore(range.end)) { + days.add(cursor); + cursor = DateTime(cursor.year, cursor.month, cursor.day + 1); + } + return Column( + children: [ + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(16, 0, 16, 4), + child: Row( + children: [ + for (var index = 0; index < 7; index += 1) + Expanded( + child: Center( + child: Text( + DateFormat.E(locale).format(DateTime(2026, 1, 5 + index)), + style: FluentTheme.of(context).typography.caption, + ), + ), + ), + ], + ), + ), + Expanded( + child: GridView.builder( + padding: const EdgeInsetsDirectional.fromSTEB(16, 0, 16, 16), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + childAspectRatio: days.length > 35 ? 1.0 : 0.82, + ), + itemCount: days.length, + itemBuilder: (context, index) { + final day = days[index]; + final dayItems = items + .where((item) => _itemOccursOn(item, day)) + .toList(); + return Card( + child: Padding( + padding: const EdgeInsets.all(5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + HyperlinkButton( + onPressed: () => onSelectDate(day), + child: Text( + '${day.day}', + style: day.month == selectedDate.month + ? null + : TextStyle( + color: FluentTheme.of(context).inactiveColor, + ), + ), + ), + for (final item in dayItems.take(2)) + _CompactScheduleItem( + item: item, + locale: locale, + onPressed: () => onOpen(item), + ), + if (dayItems.length > 2) + Text( + '+${dayItems.length - 2}', + textAlign: TextAlign.center, + style: FluentTheme.of(context).typography.caption, + ), + ], + ), + ), + ); + }, + ), + ), + ], + ); + } +} + +class _YearView extends StatelessWidget { + const _YearView({ + required this.selectedDate, + required this.items, + required this.locale, + required this.onSelectDate, + }); + + final DateTime selectedDate; + final List items; + final String locale; + final ValueChanged onSelectDate; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) => GridView.builder( + padding: const EdgeInsetsDirectional.fromSTEB(20, 0, 20, 20), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: constraints.maxWidth >= 900 ? 4 : 3, + childAspectRatio: 1.45, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + ), + itemCount: 12, + itemBuilder: (context, index) { + final month = DateTime(selectedDate.year, index + 1); + final count = items.where((item) { + final start = item.start; + return start != null && + start.year == month.year && + start.month == month.month; + }).length; + return Button( + onPressed: () => onSelectDate(month), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + DateFormat.MMMM(locale).format(month), + style: FluentTheme.of(context).typography.subtitle, + ), + const SizedBox(height: 8), + Text(AppLocalizations.of(context).scheduleItemCount(count)), + ], + ), + ); + }, + ), + ); + } +} + +class _CompactScheduleItem extends StatelessWidget { + const _CompactScheduleItem({ + required this.item, + required this.locale, + required this.onPressed, + }); + + final ScheduleItem item; + final String locale; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final start = item.start; + final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; + final label = item.allDay || start == null + ? item.title + : '${DateFormat.jm(locale).format(start)} ${item.title}'; + return Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Button( + onPressed: onPressed, + child: Row( + children: [ + Icon( + windowsBusyMaxGlyph( + task != null + ? task.completed + ? BusyMaxGlyph.check + : BusyMaxGlyph.task + : BusyMaxGlyph.calendar, + ), + size: 13, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: FluentTheme.of(context).typography.caption?.copyWith( + decoration: task?.completed == true + ? TextDecoration.lineThrough + : null, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _WindowsScheduleEmptyState extends StatelessWidget { + const _WindowsScheduleEmptyState({ + required this.searching, + required this.noVisibleSources, + }); + + final bool searching; + final bool noVisibleSources; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(windowsBusyMaxGlyph(BusyMaxGlyph.calendar), size: 40), + const SizedBox(height: 12), + Text( + searching + ? l10n.scheduleNoSearchResults + : noVisibleSources + ? l10n.scheduleNoSources + : l10n.noEventsOrTasks, + style: FluentTheme.of(context).typography.subtitle, + ), + if (searching) ...[ + const SizedBox(height: 4), + Text(l10n.scheduleNoSearchResultsDescription), + ], + ], + ), + ); + } +} + +class _ScheduleItemCard extends StatelessWidget { + const _ScheduleItemCard({ + required this.item, + required this.locale, + required this.onPressed, + }); + + final ScheduleItem item; + final String locale; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Card( + child: ListTile( + leading: Icon( + windowsBusyMaxGlyph( + item.kind == ScheduleItemKind.task + ? BusyMaxGlyph.task + : BusyMaxGlyph.calendar, + ), + ), + title: Text( + item.title, + style: task?.completed == true + ? const TextStyle(decoration: TextDecoration.lineThrough) + : null, + ), + subtitle: Text( + [ + _itemDateLabel(item, locale), + ?item.sourceName, + ].where((value) => value.isNotEmpty).join(' · '), + ), + onPressed: onPressed, + ), + ), + ); + } +} + +BusyMaxGlyph _modeGlyph(ScheduleViewMode mode) => switch (mode) { + ScheduleViewMode.day => BusyMaxGlyph.today, + ScheduleViewMode.week => BusyMaxGlyph.calendar, + ScheduleViewMode.month => BusyMaxGlyph.month, + ScheduleViewMode.year => BusyMaxGlyph.calendar, + ScheduleViewMode.agenda => BusyMaxGlyph.agenda, +}; + +String _modeLabel(AppLocalizations l10n, ScheduleViewMode mode) => + switch (mode) { + ScheduleViewMode.day => l10n.viewDay, + ScheduleViewMode.week => l10n.viewWeek, + ScheduleViewMode.month => l10n.viewMonth, + ScheduleViewMode.year => l10n.viewYear, + ScheduleViewMode.agenda => l10n.viewAgenda, + }; + +String _itemDateLabel(ScheduleItem item, String locale) { + final start = item.start; + if (start == null) return ''; + return item.allDay + ? DateFormat.yMMMd(locale).format(start) + : DateFormat.yMMMd(locale).add_jm().format(start); +} + +DateTime _dateOnly(DateTime value) => + DateTime(value.year, value.month, value.day); + +bool _sameDay(DateTime left, DateTime right) => + left.year == right.year && + left.month == right.month && + left.day == right.day; + +bool _itemOccursOn(ScheduleItem item, DateTime day) { + final start = item.start; + if (start == null) return false; + if (item is! CalendarScheduleItem) return _sameDay(start, day); + final dayStart = _dateOnly(day); + final dayEnd = DateTime(dayStart.year, dayStart.month, dayStart.day + 1); + final end = item.end ?? start; + if (end.isAtSameMomentAs(start)) return _sameDay(start, day); + return start.isBefore(dayEnd) && end.isAfter(dayStart); +} diff --git a/lib/src/ui/windows/windows_schedule_source_pane.dart b/lib/src/ui/windows/windows_schedule_source_pane.dart new file mode 100644 index 00000000..3dae090b --- /dev/null +++ b/lib/src/ui/windows/windows_schedule_source_pane.dart @@ -0,0 +1,746 @@ +import 'dart:async'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../calendar_providers/calendar_colors.dart'; +import '../../features/accounts/data/accounts_repository.dart'; +import '../../features/accounts/domain/account_collection_creation_capabilities.dart'; +import '../../features/calendar/data/calendar_collection_creation_service.dart'; +import '../../features/calendar/data/calendar_repository.dart'; +import '../../features/task_lists/data/task_lists_repository.dart'; +import '../../providers/busy_provider.dart'; +import '../../schedule/schedule_filters.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; + +class WindowsScheduleSourcePane extends ConsumerStatefulWidget { + const WindowsScheduleSourcePane({ + required this.selectedDate, + required this.accounts, + required this.calendarSources, + required this.taskLists, + required this.visibleCalendarSourceIds, + required this.visibleTaskListKeys, + required this.onDateSelected, + required this.onCalendarVisibilityChanged, + required this.onTaskListVisibilityChanged, + required this.onSourcesChanged, + super.key, + }); + + final DateTime selectedDate; + final List accounts; + final List calendarSources; + final List taskLists; + final Set visibleCalendarSourceIds; + final Set visibleTaskListKeys; + final ValueChanged onDateSelected; + final void Function(CalendarSourceEntity source, bool visible) + onCalendarVisibilityChanged; + final void Function(TaskListEntity list, bool visible) + onTaskListVisibilityChanged; + final VoidCallback onSourcesChanged; + + @override + ConsumerState createState() => + _WindowsScheduleSourcePaneState(); +} + +class _WindowsScheduleSourcePaneState + extends ConsumerState { + late DateTime _displayedMonth = DateTime( + widget.selectedDate.year, + widget.selectedDate.month, + ); + + @override + void didUpdateWidget(covariant WindowsScheduleSourcePane oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selectedDate.year != widget.selectedDate.year || + oldWidget.selectedDate.month != widget.selectedDate.month) { + _displayedMonth = DateTime( + widget.selectedDate.year, + widget.selectedDate.month, + ); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + return ColoredBox( + color: FluentTheme.of(context).cardColor, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: _MiniMonth( + displayedMonth: _displayedMonth, + selectedDate: widget.selectedDate, + locale: locale, + onPrevious: () => setState(() { + _displayedMonth = DateTime( + _displayedMonth.year, + _displayedMonth.month - 1, + ); + }), + onNext: () => setState(() { + _displayedMonth = DateTime( + _displayedMonth.year, + _displayedMonth.month + 1, + ); + }), + onSelected: widget.onDateSelected, + ), + ), + const Divider(), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + for (final account in widget.accounts) ...[ + _AccountHeader( + account: account, + capabilities: AccountCollectionCreationCapabilities.resolve( + account: account, + networkAvailability: + ref.watch(networkAvailabilityProvider).valueOrNull ?? + ref + .read(networkConnectivityMonitorProvider) + .availability, + ), + onCreateCalendar: () => unawaited(_createCalendar(account)), + onCreateTaskList: () => unawaited(_createTaskList(account)), + ), + for (final source in widget.calendarSources.where( + (source) => + source.accountId == account.id && + !source.hidden && + !source.isDeleted, + )) + _VisibilityRow( + icon: BusyMaxGlyph.calendar, + title: source.summary, + checked: widget.visibleCalendarSourceIds.contains( + source.id, + ), + onChanged: (value) => + widget.onCalendarVisibilityChanged(source, value), + menuItems: _calendarMenuItems(context, source), + ), + for (final list in widget.taskLists.where( + (list) => + list.accountId == account.id && !list.pendingDelete, + )) + _VisibilityRow( + icon: BusyMaxGlyph.task, + title: list.title, + checked: widget.visibleTaskListKeys.contains( + ScheduleTaskListKey( + accountId: list.accountId, + taskListId: list.id, + ), + ), + onChanged: (value) => + widget.onTaskListVisibilityChanged(list, value), + menuItems: _taskListMenuItems(context, account, list), + ), + ], + if (widget.calendarSources.isEmpty && widget.taskLists.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Text(l10n.scheduleNoSources), + ), + ], + ), + ), + ], + ), + ); + } + + List _calendarMenuItems( + BuildContext context, + CalendarSourceEntity source, + ) { + final l10n = AppLocalizations.of(context); + return [ + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.reminder)), + text: Text( + '${l10n.eventReminders} — ' + '${source.remindersEnabled ? l10n.onState : l10n.off}', + ), + onPressed: () => unawaited(_toggleCalendarReminders(source)), + ), + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.settings)), + text: Text(l10n.calendarColor), + onPressed: source.capabilities.canChangeCalendarColor + ? () => unawaited(_changeCalendarColor(source)) + : null, + ), + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.edit)), + text: Text(l10n.rename), + onPressed: source.capabilities.canRenameCalendar + ? () => unawaited(_renameCalendar(source)) + : null, + ), + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.delete)), + text: Text( + source.capabilities.removalMode == CalendarRemovalMode.removeFromList + ? l10n.removeFromMyCalendars + : l10n.delete, + ), + onPressed: source.capabilities.canRemoveCalendar + ? () => unawaited(_deleteCalendar(source)) + : null, + ), + ]; + } + + List _taskListMenuItems( + BuildContext context, + AccountEntity account, + TaskListEntity list, + ) { + final l10n = AppLocalizations.of(context); + final davCapabilities = list.davCollectionId == null + ? null + : ref + .watch( + davTaskCollectionCapabilitiesProvider(( + accountId: list.accountId, + taskListId: list.id, + )), + ) + .valueOrNull; + final canRename = account.provider == BusyProvider.microsoft + ? list.canRenameOrDeleteForMicrosoft + : list.davCollectionId != null + ? davCapabilities?.supportsListRename ?? false + : true; + final canDelete = account.provider == BusyProvider.microsoft + ? list.canRenameOrDeleteForMicrosoft + : list.davCollectionId != null + ? davCapabilities?.supportsListDelete ?? false + : true; + return [ + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.reminder)), + text: Text( + '${l10n.taskReminders} — ' + '${list.remindersEnabled ? l10n.onState : l10n.off}', + ), + onPressed: () => unawaited(_toggleTaskListReminders(list)), + ), + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.edit)), + text: Text(l10n.rename), + onPressed: canRename ? () => unawaited(_renameTaskList(list)) : null, + ), + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.delete)), + text: Text(list.isShared == true ? l10n.unshare : l10n.deleteList), + onPressed: canDelete ? () => unawaited(_deleteTaskList(list)) : null, + ), + ]; + } + + Future _createCalendar(AccountEntity account) async { + final title = await _textPrompt( + title: AppLocalizations.of(context).newCalendar, + actionLabel: AppLocalizations.of(context).create, + ); + if (title == null || !mounted) return; + await _runMutation(() async { + final result = await ref + .read(calendarCollectionCreationServiceProvider) + .createCalendar(accountId: account.id, title: title); + if (result.outcome == + CalendarCollectionCreationOutcome.createdRefreshPending && + mounted) { + await _showMessage( + AppLocalizations.of(context).calendarCreatedRefreshPending, + ); + } + }); + } + + Future _createTaskList(AccountEntity account) async { + final title = await _textPrompt( + title: AppLocalizations.of(context).newTaskList, + actionLabel: AppLocalizations.of(context).create, + ); + if (title == null || !mounted) return; + await _runMutation( + () => ref + .read(taskListsRepositoryForAccountProvider(account.id)) + .createTaskList(title), + ); + } + + Future _toggleCalendarReminders(CalendarSourceEntity source) => + _runMutation( + () => ref + .read(calendarRepositoryProvider) + .setSourceRemindersEnabled(source.id, !source.remindersEnabled), + ); + + Future _toggleTaskListReminders(TaskListEntity list) => _runMutation( + () => ref + .read(taskListsRepositoryForAccountProvider(list.accountId)) + .setRemindersEnabled(list.id, !list.remindersEnabled), + ); + + Future _changeCalendarColor(CalendarSourceEntity source) async { + final choices = calendarColorChoices(source.provider); + final choice = await showDialog( + context: context, + builder: (dialogContext) => ContentDialog( + title: Text(AppLocalizations.of(context).calendarColor), + constraints: const BoxConstraints(maxWidth: 420, maxHeight: 600), + content: ListView( + shrinkWrap: true, + children: [ + for (var index = 0; index < choices.length; index += 1) + ListTile( + leading: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: _colorFromHex(choices[index].backgroundColor), + shape: BoxShape.circle, + ), + ), + title: Text( + AppLocalizations.of(context).calendarColorOption(index + 1), + ), + trailing: _isCurrentCalendarColor(source, choices[index]) + ? Icon(windowsBusyMaxGlyph(BusyMaxGlyph.check)) + : null, + onPressed: () => Navigator.pop(dialogContext, choices[index]), + ), + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(context).cancel), + ), + ], + ), + ); + if (choice == null || !mounted) return; + await _runCalendarMutation( + source.accountId, + () => ref + .read(calendarRepositoryProvider) + .setSourceColor(source.id, choice), + ); + } + + Future _renameCalendar(CalendarSourceEntity source) async { + final title = await _textPrompt( + title: source.capabilities.renameMode == CalendarRenameMode.personal + ? AppLocalizations.of(context).setCustomCalendarName + : AppLocalizations.of(context).rename, + actionLabel: AppLocalizations.of(context).rename, + initialValue: source.summary, + ); + if (title == null || title == source.summary || !mounted) return; + await _runCalendarMutation( + source.accountId, + () => ref + .read(calendarRepositoryProvider) + .renameLocalSource(source.id, title), + ); + } + + Future _deleteCalendar(CalendarSourceEntity source) async { + final l10n = AppLocalizations.of(context); + final removeFromList = + source.capabilities.removalMode == CalendarRemovalMode.removeFromList; + final confirmed = await _confirm( + title: removeFromList ? l10n.removeFromMyCalendars : l10n.delete, + message: removeFromList + ? l10n.removeCalendarConfirmation(source.summary) + : l10n.deleteCalendarConfirmation(source.summary), + actionLabel: removeFromList ? l10n.removeAction : l10n.delete, + ); + if (!confirmed || !mounted) return; + await _runCalendarMutation( + source.accountId, + () => ref.read(calendarRepositoryProvider).deleteLocalSource(source.id), + ); + } + + Future _renameTaskList(TaskListEntity list) async { + final l10n = AppLocalizations.of(context); + final title = await _textPrompt( + title: l10n.renameList, + actionLabel: l10n.rename, + initialValue: list.title, + ); + if (title == null || title == list.title || !mounted) return; + await _runMutation( + () => ref + .read(taskListsRepositoryForAccountProvider(list.accountId)) + .renameTaskList(list.id, title), + ); + } + + Future _deleteTaskList(TaskListEntity list) async { + final l10n = AppLocalizations.of(context); + final confirmed = await _confirm( + title: list.isShared == true ? l10n.unshare : l10n.deleteList, + message: list.isShared == true + ? l10n.unshareTaskListConfirmation(list.title) + : l10n.deleteTaskListConfirmation(list.title), + actionLabel: list.isShared == true ? l10n.unshare : l10n.delete, + ); + if (!confirmed || !mounted) return; + await _runMutation( + () => ref + .read(taskListsRepositoryForAccountProvider(list.accountId)) + .deleteTaskList(list.id), + ); + } + + Future _runCalendarMutation( + String accountId, + Future Function() operation, + ) => _runMutation(() async { + await operation(); + ref + .read(pendingCalendarMutationSyncRequesterForAccountProvider(accountId)) + .request(); + }); + + Future _runMutation(Future Function() operation) async { + try { + await operation(); + if (mounted) widget.onSourcesChanged(); + } on Object { + if (mounted) { + await _showMessage(AppLocalizations.of(context).operationFailed); + } + } + } + + Future _textPrompt({ + required String title, + required String actionLabel, + String initialValue = '', + }) async { + final controller = TextEditingController(text: initialValue); + final value = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(title), + content: InfoLabel( + label: AppLocalizations.of(context).title, + child: TextBox(controller: controller, autofocus: true), + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(context).cancel), + ), + FilledButton( + onPressed: () => + Navigator.pop(dialogContext, controller.text.trim()), + child: Text(actionLabel), + ), + ], + ), + ); + controller.dispose(); + return value == null || value.trim().isEmpty ? null : value.trim(); + } + + Future _confirm({ + required String title, + required String message, + required String actionLabel, + }) async => + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(title), + content: Text(message), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(AppLocalizations.of(context).cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(actionLabel), + ), + ], + ), + ) ?? + false; + + Future _showMessage(String message) => showDialog( + context: context, + builder: (dialogContext) => ContentDialog( + content: Text(message), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(context).close), + ), + ], + ), + ); +} + +class _AccountHeader extends StatelessWidget { + const _AccountHeader({ + required this.account, + required this.capabilities, + required this.onCreateCalendar, + required this.onCreateTaskList, + }); + + final AccountEntity account; + final AccountCollectionCreationCapabilities capabilities; + final VoidCallback onCreateCalendar; + final VoidCallback onCreateTaskList; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsetsDirectional.fromSTEB(14, 10, 8, 4), + child: Row( + children: [ + Expanded( + child: Text( + account.displayLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: FluentTheme.of(context).typography.caption, + ), + ), + if (capabilities.hasActions) + DropDownButton( + title: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.add), size: 16), + items: [ + if (capabilities.supportsCalendarCreation) + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.calendar)), + text: Text(AppLocalizations.of(context).newCalendar), + onPressed: capabilities.calendarActionEnabled + ? onCreateCalendar + : null, + ), + if (capabilities.supportsTaskListCreation) + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.task)), + text: Text(AppLocalizations.of(context).newTaskList), + onPressed: capabilities.taskListActionEnabled + ? onCreateTaskList + : null, + ), + ], + ), + ], + ), + ); +} + +class _VisibilityRow extends StatelessWidget { + const _VisibilityRow({ + required this.icon, + required this.title, + required this.checked, + required this.onChanged, + required this.menuItems, + }); + + final BusyMaxGlyph icon; + final String title; + final bool checked; + final ValueChanged onChanged; + final List menuItems; + + @override + Widget build(BuildContext context) { + return Semantics( + label: title, + checked: checked, + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(12, 3, 8, 3), + child: Row( + children: [ + Checkbox( + checked: checked, + onChanged: (value) => onChanged(value ?? false), + ), + const SizedBox(width: 8), + Icon(windowsBusyMaxGlyph(icon), size: 16), + const SizedBox(width: 8), + Expanded( + child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + DropDownButton( + title: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.more), size: 16), + items: menuItems, + ), + ], + ), + ), + ); + } +} + +Color _colorFromHex(String source) { + final hex = source.trim().replaceFirst('#', ''); + final parsed = int.tryParse(hex, radix: 16); + return Color(parsed == null ? 0xFF0078D4 : 0xFF000000 | parsed); +} + +bool _isCurrentCalendarColor( + CalendarSourceEntity source, + CalendarColorChoice choice, +) => switch (source.provider) { + BusyProvider.google => + source.backgroundColor?.toLowerCase() == + choice.backgroundColor.toLowerCase(), + BusyProvider.microsoft => source.colorId == choice.providerValue, + _ => false, +}; + +class _MiniMonth extends StatelessWidget { + const _MiniMonth({ + required this.displayedMonth, + required this.selectedDate, + required this.locale, + required this.onPrevious, + required this.onNext, + required this.onSelected, + }); + + final DateTime displayedMonth; + final DateTime selectedDate; + final String locale; + final VoidCallback onPrevious; + final VoidCallback onNext; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final first = DateTime(displayedMonth.year, displayedMonth.month); + final gridStart = DateTime( + first.year, + first.month, + first.day - (first.weekday - DateTime.monday), + ); + final days = [ + for (var offset = 0; offset < 42; offset += 1) + DateTime(gridStart.year, gridStart.month, gridStart.day + offset), + ]; + final weekdayBase = DateTime(2026, 1, 5); + return Column( + children: [ + Row( + children: [ + Expanded( + child: Text( + DateFormat.yMMMM(locale).format(displayedMonth), + style: FluentTheme.of(context).typography.bodyStrong, + ), + ), + IconButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.previous)), + onPressed: onPrevious, + ), + IconButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.next)), + onPressed: onNext, + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + for (var index = 0; index < 7; index += 1) + Expanded( + child: Center( + child: Text( + DateFormat.E( + locale, + ).format(weekdayBase.add(Duration(days: index))), + maxLines: 1, + style: FluentTheme.of(context).typography.caption, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + for (var week = 0; week < 6; week += 1) + Row( + children: [ + for (var weekday = 0; weekday < 7; weekday += 1) + Expanded( + child: _MiniDayButton( + day: days[week * 7 + weekday], + inMonth: + days[week * 7 + weekday].month == displayedMonth.month, + selected: _sameDay(days[week * 7 + weekday], selectedDate), + onSelected: onSelected, + ), + ), + ], + ), + ], + ); + } +} + +class _MiniDayButton extends StatelessWidget { + const _MiniDayButton({ + required this.day, + required this.inMonth, + required this.selected, + required this.onSelected, + }); + + final DateTime day; + final bool inMonth; + final bool selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final child = SizedBox( + height: 26, + child: Center(child: Text('${day.day}')), + ); + return Padding( + padding: const EdgeInsets.all(1), + child: Opacity( + opacity: inMonth || selected ? 1 : 0.55, + child: selected + ? FilledButton(onPressed: () => onSelected(day), child: child) + : Button(onPressed: () => onSelected(day), child: child), + ), + ); + } +} + +bool _sameDay(DateTime left, DateTime right) => + left.year == right.year && + left.month == right.month && + left.day == right.day; diff --git a/lib/src/ui/windows/windows_settings_page.dart b/lib/src/ui/windows/windows_settings_page.dart new file mode 100644 index 00000000..04533605 --- /dev/null +++ b/lib/src/ui/windows/windows_settings_page.dart @@ -0,0 +1,1000 @@ +import 'dart:async'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../features/accounts/data/accounts_repository.dart'; +import '../../features/notifications/desktop_notification_backend.dart'; +import '../../l10n/app_locale.dart'; +import '../../platform/common/desktop_services.dart'; +import '../../providers/busy_provider.dart'; +import '../../webcal/webcal_subscription_service.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_account_removal_dialog.dart'; +import 'windows_busymax_glyphs.dart'; +import 'windows_calendar_activation_flows.dart'; +import 'windows_diagnostics_dialog.dart'; +import 'windows_feedback_dialog.dart'; +import 'windows_keyboard_shortcuts_dialog.dart'; + +const _busyMaxRepositoryUrl = 'https://github.com/busystack/busymax/'; +const _apacheLicenseUrl = 'https://www.apache.org/licenses/LICENSE-2.0'; +const _systemLocaleTag = 'system'; + +class WindowsSettingsPage extends ConsumerWidget { + const WindowsSettingsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final settings = ref.watch(appSettingsControllerProvider); + final controller = ref.read(appSettingsControllerProvider.notifier); + final autostart = ref.watch(launchAtLoginStateProvider); + final accounts = ref.watch(accountManagementStreamProvider); + final subscriptions = ref.watch(webCalSubscriptionsProvider); + final config = ref.watch(buildConfigProvider); + final notificationReadiness = ref.watch( + desktopNotificationReadinessProvider, + ); + return ScaffoldPage.scrollable( + header: PageHeader(title: Text(l10n.settings)), + children: [ + _SectionTitle(l10n.appearance), + Card( + child: _SettingsRow( + icon: BusyMaxGlyph.settings, + title: l10n.theme, + child: ComboBox( + value: settings.themeModePreference, + items: [ + ComboBoxItem( + value: BusyMaxThemeModePreference.system, + child: Text(l10n.themeSystem), + ), + ComboBoxItem( + value: BusyMaxThemeModePreference.light, + child: Text(l10n.themeLight), + ), + ComboBoxItem( + value: BusyMaxThemeModePreference.dark, + child: Text(l10n.themeDark), + ), + ], + onChanged: (value) { + if (value != null) controller.setThemeModePreference(value); + }, + ), + ), + ), + const SizedBox(height: 20), + _SectionTitle(l10n.scheduleDisplaySettings), + Card( + child: Column( + children: [ + _SettingsRow( + icon: BusyMaxGlyph.calendar, + title: l10n.scheduleDayStartsAt, + child: TimePicker( + selected: _minuteDateTime(settings.scheduleDayStartMinute), + onChanged: (value) => controller.setScheduleDayStartMinute( + value.hour * 60 + value.minute, + ), + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.calendar, + title: l10n.scheduleDayEndsAt, + child: TimePicker( + selected: _minuteDateTime(settings.scheduleDayEndMinute), + onChanged: (value) => controller.setScheduleDayEndMinute( + value.hour == 0 && value.minute == 0 + ? 24 * 60 + : value.hour * 60 + value.minute, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + _SectionTitle(l10n.settingsSystem), + Card( + child: Column( + children: [ + _SettingsRow( + icon: BusyMaxGlyph.settings, + title: l10n.currentLocale, + child: ComboBox( + value: settings.localeTag ?? _systemLocaleTag, + items: [ + ComboBoxItem( + value: _systemLocaleTag, + child: Text(l10n.themeSystem), + ), + for (final option in busyMaxLocaleOptions) + ComboBoxItem( + value: option.tag, + child: Text(option.endonym), + ), + ], + onChanged: (value) { + if (value != null) { + controller.setLocaleTag( + value == _systemLocaleTag ? null : value, + ); + } + }, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.more, + title: l10n.runInBackgroundWhenClosed, + child: ToggleSwitch( + checked: settings.runInBackgroundWhenClosed, + onChanged: controller.setRunInBackgroundWhenClosed, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.information, + title: l10n.showTrayIcon, + child: ToggleSwitch( + checked: settings.showTrayIcon, + onChanged: controller.setShowTrayIcon, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.signOut, + title: l10n.startMinimizedToTray, + child: ToggleSwitch( + checked: settings.startMinimizedToTray, + onChanged: controller.setStartMinimizedToTray, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.sync, + title: l10n.launchAtLogin, + subtitle: _autostartDescription(l10n, autostart), + child: autostart.when( + loading: () => const ProgressRing(), + error: (_, _) => + Icon(windowsBusyMaxGlyph(BusyMaxGlyph.warning)), + data: (state) => ToggleSwitch( + checked: state == DesktopAutostartState.enabled, + onChanged: + state == DesktopAutostartState.disabledByUser || + state == DesktopAutostartState.disabledByPolicy || + state == DesktopAutostartState.unavailable + ? null + : (enabled) => unawaited( + _setWindowsAutostart(context, ref, enabled), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + InfoBar( + title: Text(l10n.windowsReminderExitNotice), + severity: InfoBarSeverity.info, + ), + const SizedBox(height: 20), + _SectionTitle(l10n.notifications), + if (!notificationReadiness.canNotify) ...[ + WindowsNotificationReadinessInfoBar(readiness: notificationReadiness), + const SizedBox(height: 8), + ], + Card( + child: Column( + children: [ + _SettingsRow( + icon: BusyMaxGlyph.reminder, + title: l10n.eventReminders, + child: ToggleSwitch( + checked: settings.notifyEventReminders, + onChanged: controller.setNotifyEventReminders, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.task, + title: l10n.taskReminders, + child: ToggleSwitch( + checked: settings.notifyTaskReminders, + onChanged: controller.setNotifyTaskReminders, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.today, + title: l10n.notifyDueToday, + child: ToggleSwitch( + checked: settings.notifyDueToday, + onChanged: controller.setNotifyDueToday, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.sync, + title: l10n.notifySyncFailures, + child: ToggleSwitch( + checked: settings.notifySyncFailures, + onChanged: controller.setNotifySyncFailures, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.warning, + title: l10n.notifyConflicts, + child: ToggleSwitch( + checked: settings.notifyConflicts, + onChanged: controller.setNotifyConflicts, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.privacy, + title: l10n.notificationDetailLevel, + child: ComboBox( + value: settings.notificationDetailLevel, + items: [ + ComboBoxItem( + value: NotificationDetailLevel.private, + child: Text(l10n.notificationDetailPrivate), + ), + ComboBoxItem( + value: NotificationDetailLevel.normal, + child: Text(l10n.notificationDetailNormal), + ), + ], + onChanged: (value) { + if (value != null) { + controller.setNotificationDetailLevel(value); + } + }, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.reminder, + title: l10n.quietHours, + subtitle: l10n.quietHoursDescription, + child: ToggleSwitch( + checked: settings.quietHoursEnabled, + onChanged: controller.setQuietHoursEnabled, + ), + ), + if (settings.quietHoursEnabled) ...[ + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.previous, + title: l10n.quietHoursStart, + child: TimePicker( + selected: _timeStringDateTime(settings.quietHoursStart), + onChanged: (value) => + controller.setQuietHoursStart(_timeString(value)), + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.next, + title: l10n.quietHoursEnd, + child: TimePicker( + selected: _timeStringDateTime(settings.quietHoursEnd), + onChanged: (value) => + controller.setQuietHoursEnd(_timeString(value)), + ), + ), + ], + ], + ), + ), + const SizedBox(height: 20), + _SectionTitle(l10n.privacy), + Card( + child: _SettingsRow( + icon: BusyMaxGlyph.privacy, + title: l10n.redactTaskContentInDiagnostics, + child: ToggleSwitch( + checked: settings.redactTaskContentInDiagnostics, + onChanged: controller.setRedactTaskContentInDiagnostics, + ), + ), + ), + const SizedBox(height: 20), + _SectionTitle(l10n.accounts), + Card( + child: accounts.when( + loading: () => const Padding( + padding: EdgeInsets.all(20), + child: Center(child: ProgressRing()), + ), + error: (_, _) => InfoBar( + title: Text(l10n.operationFailed), + severity: InfoBarSeverity.error, + ), + data: (values) => Column( + children: [ + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.add)), + title: Text(l10n.connectAccountAction), + onPressed: () => context.go('/sign-in?add=true'), + ), + if (values.isNotEmpty) const Divider(), + for (var index = 0; index < values.length; index++) ...[ + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.account)), + title: Text( + values[index].displayName ?? + values[index].email ?? + values[index].id, + ), + subtitle: Text(values[index].provider.storageValue), + trailing: DropDownButton( + title: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.more)), + items: [ + MenuFlyoutItem( + leading: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.delete), + ), + text: Text(l10n.removeAccount), + onPressed: () => unawaited( + _removeWindowsAccount(context, ref, values[index]), + ), + ), + ], + ), + ), + if (index != values.length - 1) const Divider(), + ], + ], + ), + ), + ), + const SizedBox(height: 20), + _SectionTitle(l10n.calendarImport), + Card( + child: Column( + children: [ + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.calendar)), + title: Text(l10n.importIcsFile), + subtitle: Text(l10n.calendarImportDescription), + onPressed: () => showWindowsIcsImportFlow(context, ref), + ), + const Divider(), + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.add)), + title: Text(l10n.addCalendarSubscription), + subtitle: Text(l10n.calendarSubscriptionsDescription), + onPressed: () => + showWindowsWebCalSubscriptionFlow(context, ref), + ), + ...subscriptions.maybeWhen( + data: (values) => [ + for (final subscription in values) ...[ + const Divider(), + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.calendar)), + title: Text(subscription.name), + subtitle: Text(subscription.safeOrigin), + trailing: DropDownButton( + title: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.more)), + items: [ + MenuFlyoutItem( + leading: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.refresh), + ), + text: Text(l10n.refresh), + onPressed: () => unawaited( + _refreshWindowsSubscription( + context, + ref, + subscription, + ), + ), + ), + MenuFlyoutItem( + leading: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.edit), + ), + text: Text(l10n.rename), + onPressed: () => unawaited( + _renameWindowsSubscription( + context, + ref, + subscription, + ), + ), + ), + MenuFlyoutItem( + leading: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.settings), + ), + text: Text(l10n.calendarColor), + onPressed: () => unawaited( + _colorWindowsSubscription( + context, + ref, + subscription, + ), + ), + ), + MenuFlyoutItem( + leading: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.delete), + ), + text: Text(l10n.unsubscribe), + onPressed: () => unawaited( + _unsubscribeWindowsSubscription( + context, + ref, + subscription, + ), + ), + ), + ], + ), + ), + ], + ], + orElse: () => const [], + ), + ], + ), + ), + const SizedBox(height: 20), + _SectionTitle(l10n.aboutBusyMax), + Card( + child: Column( + children: [ + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.information)), + title: Text(l10n.aboutBusyMax), + subtitle: Text(l10n.aboutBusyMaxDescription), + onPressed: () => showWindowsAboutDialog( + context, + packageVersion: config.windowsPackageVersion, + privacyPolicyUrl: config.privacyPolicyUrl, + supportUrl: config.supportUrl, + homepageUrl: config.homepageUrl, + ), + ), + const Divider(), + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.privacy)), + title: Text(l10n.privacy), + onPressed: _httpsUri(config.privacyPolicyUrl) == null + ? null + : () => launchUrl(_httpsUri(config.privacyPolicyUrl)!), + ), + const Divider(), + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.feedback)), + title: Text(l10n.sendFeedback), + onPressed: () => showWindowsFeedbackDialog(context, ref), + ), + const Divider(), + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.diagnostics)), + title: Text(l10n.diagnostics), + onPressed: () => showWindowsDiagnosticsDialog(context, ref), + ), + const Divider(), + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.more)), + title: Text(l10n.keyboardShortcuts), + onPressed: () => showWindowsKeyboardShortcutsDialog(context), + ), + const Divider(), + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.feedback)), + title: Text(l10n.windowsSupport), + onPressed: _httpsUri(config.supportUrl) == null + ? null + : () => launchUrl(_httpsUri(config.supportUrl)!), + ), + const Divider(), + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.information)), + title: Text(l10n.website), + onPressed: _httpsUri(config.homepageUrl) == null + ? null + : () => launchUrl(_httpsUri(config.homepageUrl)!), + ), + ], + ), + ), + const SizedBox(height: 32), + ], + ); + } +} + +class WindowsNotificationReadinessInfoBar extends StatelessWidget { + const WindowsNotificationReadinessInfoBar({ + required this.readiness, + super.key, + }); + + final DesktopNotificationReadiness readiness; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final unpackaged = + readiness.state == + DesktopNotificationReadinessState.unavailableUnpackaged; + return InfoBar( + title: Text(l10n.windowsNotificationsUnavailable), + content: Text( + unpackaged + ? l10n.windowsNotificationsUnpackaged + : l10n.windowsNotificationsInstalledFailure, + ), + severity: unpackaged ? InfoBarSeverity.warning : InfoBarSeverity.error, + ); + } +} + +Future _setWindowsAutostart( + BuildContext context, + WidgetRef ref, + bool enabled, +) async { + try { + await ref.read(desktopAutostartServiceProvider).setEnabled(enabled); + ref.invalidate(launchAtLoginStateProvider); + } on Object { + ref.invalidate(launchAtLoginStateProvider); + if (context.mounted) { + await _showWindowsMessage( + context, + AppLocalizations.of(context).launchAtLoginFailed, + ); + } + } +} + +Future _removeWindowsAccount( + BuildContext context, + WidgetRef ref, + AccountEntity account, +) async { + final l10n = AppLocalizations.of(context); + final options = await showWindowsAccountRemovalDialog( + context, + accountLabel: account.displayLabel, + canRevokeGoogleAuthorization: + account.provider == BusyProvider.google && account.isSignedIn, + ); + if (options == null || !context.mounted) return; + try { + final isDav = + account.provider == BusyProvider.appleICloud || + account.provider == BusyProvider.nextcloud; + final authResult = isDav + ? null + : await ref + .read(authRepositoryProvider) + .removeAccount( + accountId: account.id, + revokeAuthorization: options.revokeGoogleAuthorization, + ); + final davResult = isDav + ? await ref + .read(davAccountOnboardingServiceProvider) + .removeAccount(account.id) + : null; + final remaining = await ref + .read(accountsRepositoryProvider) + .listVisibleAccounts(); + ref.read(selectedAccountIdProvider.notifier).state = + remaining.firstOrNull?.id; + await ref.read(authSessionControllerProvider.notifier).load(); + if (!context.mounted) return; + if (authResult?.authorizationRevocationFailed ?? false) { + await _showWindowsMessage(context, l10n.accountRemovedGoogleRevokeFailed); + } else if (davResult?.remoteRevocationAttempted == true && + !davResult!.remoteRevocationSucceeded) { + await _showWindowsMessage( + context, + l10n.nextcloudAccountRemovedRevokeFailed, + ); + } + if (remaining.isEmpty && context.mounted) context.go('/sign-in'); + } on Object { + if (context.mounted) { + await _showWindowsMessage(context, l10n.removeAccountFailed); + } + } +} + +Future _refreshWindowsSubscription( + BuildContext context, + WidgetRef ref, + WebCalSubscriptionEntity subscription, +) => _runWindowsSubscriptionOperation( + context, + () => ref + .read(webCalSubscriptionServiceProvider) + .refreshSubscription(subscription.id, force: true), +); + +Future _renameWindowsSubscription( + BuildContext context, + WidgetRef ref, + WebCalSubscriptionEntity subscription, +) async { + final l10n = AppLocalizations.of(context); + final value = await _showWindowsTextPrompt( + context, + title: l10n.rename, + label: l10n.subscriptionName, + initialValue: subscription.name, + actionLabel: l10n.rename, + ); + if (value == null || !context.mounted) return; + await _runWindowsSubscriptionOperation( + context, + () => ref + .read(webCalSubscriptionServiceProvider) + .renameSubscription(subscription.id, value), + ); +} + +Future _colorWindowsSubscription( + BuildContext context, + WidgetRef ref, + WebCalSubscriptionEntity subscription, +) async { + final l10n = AppLocalizations.of(context); + final value = await _showWindowsTextPrompt( + context, + title: l10n.calendarColor, + label: l10n.subscriptionColor, + initialValue: subscription.color ?? '', + actionLabel: l10n.save, + message: l10n.subscriptionColorHelp, + ); + if (value == null || !context.mounted) return; + await _runWindowsSubscriptionOperation( + context, + () => ref + .read(webCalSubscriptionServiceProvider) + .changeSubscriptionColor(subscription.id, value), + ); +} + +Future _unsubscribeWindowsSubscription( + BuildContext context, + WidgetRef ref, + WebCalSubscriptionEntity subscription, +) async { + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.unsubscribeCalendarTitle(subscription.name)), + content: Text(l10n.unsubscribeCalendarConfirmation), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.unsubscribe), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + await _runWindowsSubscriptionOperation( + context, + () => ref + .read(webCalSubscriptionServiceProvider) + .unsubscribe(subscription.id), + ); +} + +Future _runWindowsSubscriptionOperation( + BuildContext context, + Future Function() operation, +) async { + try { + await operation(); + } on Object { + if (context.mounted) { + final l10n = AppLocalizations.of(context); + await _showWindowsMessage( + context, + l10n.subscriptionOperationFailed(l10n.operationFailed), + ); + } + } +} + +Future _showWindowsTextPrompt( + BuildContext context, { + required String title, + required String label, + required String initialValue, + required String actionLabel, + String? message, +}) async { + final controller = TextEditingController(text: initialValue); + final value = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(title), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (message != null) ...[Text(message), const SizedBox(height: 12)], + InfoLabel( + label: label, + child: TextBox(controller: controller, autofocus: true), + ), + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(dialogContext).cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, controller.text), + child: Text(actionLabel), + ), + ], + ), + ); + controller.dispose(); + return value; +} + +Future _showWindowsMessage(BuildContext context, String message) { + return showDialog( + context: context, + builder: (context) => ContentDialog( + content: Text(message), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context).close), + ), + ], + ), + ); +} + +String _autostartDescription( + AppLocalizations l10n, + AsyncValue value, +) => value.maybeWhen( + data: (state) => switch (state) { + DesktopAutostartState.disabledByUser => l10n.windowsStartupDisabledByUser, + DesktopAutostartState.disabledByPolicy => + l10n.windowsStartupDisabledByPolicy, + DesktopAutostartState.unavailable => l10n.windowsStartupUnavailable, + _ => l10n.launchAtLoginDescription, + }, + orElse: () => l10n.launchAtLoginDescription, +); + +Uri? _httpsUri(String value) { + final uri = Uri.tryParse(value.trim()); + return uri?.scheme == 'https' && uri!.host.isNotEmpty ? uri : null; +} + +DateTime _minuteDateTime(int minute) => + DateTime(2000, 1, 1, (minute ~/ 60) % 24, minute % 60); + +DateTime _timeStringDateTime(String value) { + final parts = value.split(':'); + return DateTime( + 2000, + 1, + 1, + parts.isEmpty ? 0 : int.tryParse(parts[0]) ?? 0, + parts.length < 2 ? 0 : int.tryParse(parts[1]) ?? 0, + ); +} + +String _timeString(DateTime value) => + '${value.hour.toString().padLeft(2, '0')}:' + '${value.minute.toString().padLeft(2, '0')}'; + +class _SectionTitle extends StatelessWidget { + const _SectionTitle(this.text); + + final String text; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsetsDirectional.only(bottom: 8), + child: Text(text, style: FluentTheme.of(context).typography.subtitle), + ); +} + +class _SettingsRow extends StatelessWidget { + const _SettingsRow({ + required this.icon, + required this.title, + required this.child, + this.subtitle, + }); + + final BusyMaxGlyph icon; + final String title; + final String? subtitle; + final Widget child; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final textScale = MediaQuery.textScalerOf(context).scale(1); + final compact = constraints.maxWidth < 520 || textScale > 1.3; + final label = Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(windowsBusyMaxGlyph(icon)), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title), + if (subtitle != null) + Text( + subtitle!, + style: FluentTheme.of(context).typography.caption, + ), + ], + ), + ), + ], + ); + return Padding( + padding: const EdgeInsets.all(16), + child: compact + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + label, + const SizedBox(height: 12), + Align( + alignment: AlignmentDirectional.centerEnd, + child: child, + ), + ], + ) + : Row( + children: [ + Expanded(child: label), + const SizedBox(width: 16), + child, + ], + ), + ); + }, + ); +} + +Future showWindowsAboutDialog( + BuildContext context, { + required String packageVersion, + required String privacyPolicyUrl, + required String supportUrl, + required String homepageUrl, +}) async { + final packageInfo = await PackageInfo.fromPlatform(); + if (!context.mounted) return; + await showDialog( + context: context, + builder: (context) { + final l10n = AppLocalizations.of(context); + return ContentDialog( + title: Text(l10n.aboutBusyMax), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.aboutBusyMaxDescription), + const SizedBox(height: 12), + Text('${l10n.windowsProductVersionLabel}: ${packageInfo.version}'), + Text( + '${l10n.windowsPackageVersionLabel}: ' + '${packageVersion.isEmpty ? l10n.windowsUnpackaged : packageVersion}', + ), + const SizedBox(height: 12), + Text('Copyright © 2026 BusyStack'), + Text(l10n.apacheLicenseName), + const SizedBox(height: 8), + HyperlinkButton( + onPressed: () => + unawaited(launchUrl(Uri.parse(_apacheLicenseUrl))), + child: Text(l10n.license), + ), + HyperlinkButton( + onPressed: () => + unawaited(launchUrl(Uri.parse(_busyMaxRepositoryUrl))), + child: Text(l10n.sourceCode), + ), + if (_httpsUri(privacyPolicyUrl) case final privacyUri?) + HyperlinkButton( + onPressed: () => unawaited(launchUrl(privacyUri)), + child: Text(l10n.privacy), + ), + if (_httpsUri(supportUrl) case final supportUri?) + HyperlinkButton( + onPressed: () => unawaited(launchUrl(supportUri)), + child: Text(l10n.windowsSupport), + ), + if (_httpsUri(homepageUrl) case final homepageUri?) + HyperlinkButton( + onPressed: () => unawaited(launchUrl(homepageUri)), + child: Text(l10n.website), + ), + ], + ), + actions: [ + Button( + onPressed: () => unawaited(showWindowsLicensesDialog(context)), + child: Text(l10n.windowsThirdPartyLicenses), + ), + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + ); + }, + ); +} + +Future showWindowsLicensesDialog(BuildContext context) async { + final entries = await LicenseRegistry.licenses.toList(); + if (!context.mounted) return; + await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(AppLocalizations.of(context).windowsThirdPartyLicenses), + constraints: const BoxConstraints(maxWidth: 720, maxHeight: 640), + content: ListView.builder( + itemCount: entries.length, + itemBuilder: (context, index) { + final entry = entries[index]; + return Expander( + header: Text(entry.packages.join(', ')), + content: SelectableText( + entry.paragraphs.map((paragraph) => paragraph.text).join('\n\n'), + ), + ); + }, + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context).close), + ), + ], + ), + ); +} diff --git a/lib/src/ui/windows/windows_sign_in_page.dart b/lib/src/ui/windows/windows_sign_in_page.dart new file mode 100644 index 00000000..5b611596 --- /dev/null +++ b/lib/src/ui/windows/windows_sign_in_page.dart @@ -0,0 +1,217 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../dav/dav_errors.dart'; +import '../../dav/http/dav_http_transport.dart'; +import '../../features/auth/data/auth_repository.dart'; +import '../../providers/busy_provider.dart'; +import 'windows_dav_account_dialogs.dart'; + +class WindowsSignInPage extends ConsumerStatefulWidget { + const WindowsSignInPage({super.key, this.addingAccount = false}); + + final bool addingAccount; + + @override + ConsumerState createState() => _WindowsSignInPageState(); +} + +class _WindowsSignInPageState extends ConsumerState { + BusyProvider? _connectingDavProvider; + DavCancellationToken? _davCancellation; + String? _davError; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final session = ref.watch(authSessionControllerProvider); + final config = ref.watch(buildConfigProvider); + if (session.isSignedIn && !widget.addingAccount) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) context.go('/schedule'); + }); + } + final busy = + session.status == AuthSessionStatus.signingIn || + _connectingDavProvider != null; + return NavigationView( + content: ScaffoldPage( + content: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Card( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + child: Image.asset( + 'assets/branding/busymax-logo.png', + width: 96, + height: 96, + semanticLabel: l10n.appTitle, + ), + ), + const SizedBox(height: 24), + Text( + l10n.onboardingSetupTitle, + textAlign: TextAlign.center, + style: FluentTheme.of(context).typography.title, + ), + const SizedBox(height: 8), + Text( + l10n.providerConnectionDescription, + textAlign: TextAlign.center, + ), + if (session.message != null || _davError != null) ...[ + const SizedBox(height: 16), + InfoBar( + title: Text(_davError ?? session.message!), + severity: InfoBarSeverity.error, + ), + ], + const SizedBox(height: 24), + FilledButton( + onPressed: busy || !config.hasGoogleOAuthClientId + ? null + : () => _signIn( + ref + .read(authSessionControllerProvider.notifier) + .signIn, + ), + child: Text(l10n.signInWithGoogle), + ), + const SizedBox(height: 8), + Button( + onPressed: busy || !config.hasMicrosoftOAuthClientId + ? null + : () => _signIn( + ref + .read(authSessionControllerProvider.notifier) + .signInWithMicrosoft, + ), + child: Text(l10n.signInWithMicrosoft), + ), + const SizedBox(height: 8), + Button( + onPressed: busy + ? null + : () => _connectDav(BusyProvider.appleICloud), + child: Text(l10n.addAppleICloudAccount), + ), + const SizedBox(height: 8), + Button( + onPressed: busy + ? null + : () => _connectDav(BusyProvider.nextcloud), + child: Text(l10n.addNextcloudAccount), + ), + if (busy) ...[ + const SizedBox(height: 20), + const Center(child: ProgressRing()), + const SizedBox(height: 12), + Button( + onPressed: _cancelConnection, + child: Text(l10n.cancel), + ), + ], + if (widget.addingAccount && !busy) ...[ + const SizedBox(height: 8), + Button( + onPressed: () => context.go('/settings'), + child: Text(l10n.cancel), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } + + Future _signIn(Future Function() action) async { + setState(() => _davError = null); + try { + await action(); + } finally { + if (mounted) { + try { + await ref.read(desktopWindowServiceProvider).showWindow(); + } on Object { + // Authentication state remains authoritative if foregrounding is + // temporarily denied by Windows. + } + } + } + if (!mounted) return; + if (ref.read(authSessionControllerProvider).isSignedIn) { + context.go(widget.addingAccount ? '/settings' : '/schedule'); + } + } + + Future _connectDav(BusyProvider provider) async { + WindowsAppleCredentialInput? apple; + String? server; + if (provider == BusyProvider.appleICloud) { + apple = await showWindowsAppleICloudDialog(context); + } else { + server = await showWindowsNextcloudServerDialog(context); + } + if (!mounted || + (provider == BusyProvider.appleICloud && apple == null) || + (provider == BusyProvider.nextcloud && server == null)) { + return; + } + setState(() { + _connectingDavProvider = provider; + _davError = null; + }); + final cancellation = DavCancellationToken(); + _davCancellation = cancellation; + try { + final onboarding = ref.read(davAccountOnboardingServiceProvider); + if (provider == BusyProvider.appleICloud) { + await onboarding.connectAppleICloud( + email: apple!.email, + appSpecificPassword: apple.password, + cancellationToken: cancellation, + ); + } else { + await onboarding.connectNextcloud( + enteredServer: server!, + cancellationToken: cancellation, + ); + } + await ref.read(authSessionControllerProvider.notifier).load(); + if (mounted) { + context.go(widget.addingAccount ? '/settings' : '/schedule'); + } + } on Object catch (error) { + if (error is DavException && error.kind == DavErrorKind.cancelled) return; + if (mounted) { + setState(() => _davError = authErrorMessage(error)); + } + } finally { + _davCancellation = null; + if (mounted) setState(() => _connectingDavProvider = null); + } + } + + Future _cancelConnection() async { + if (_connectingDavProvider != null) { + _davCancellation?.cancel(); + if (_connectingDavProvider == BusyProvider.nextcloud) { + ref.read(davAccountOnboardingServiceProvider).cancelNextcloudLogin(); + } + if (mounted) setState(() => _connectingDavProvider = null); + return; + } + await ref.read(authSessionControllerProvider.notifier).cancelSignIn(); + } +} diff --git a/lib/src/ui/windows/windows_task_create_fields.dart b/lib/src/ui/windows/windows_task_create_fields.dart new file mode 100644 index 00000000..938346ac --- /dev/null +++ b/lib/src/ui/windows/windows_task_create_fields.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; + +import '../../dav/ical/ical_task_alarm.dart'; +import '../../features/recurrence/domain/recurrence_rule.dart'; +import '../../features/tasks/domain/task_capabilities.dart'; +import '../../providers/busy_provider.dart'; + +Map buildWindowsTaskCreateFields({ + required TaskCollectionCapabilities capability, + required BusyProvider provider, + required DateTime? due, + required DateTime? start, + required DateTime? reminder, + required List alarms, + required bool scheduledAllDay, + required String timeZone, + required RecurrenceRule recurrence, + required String importance, + required String status, + required int priority, + required int progress, + required String location, + required String taskUrl, + required String classification, + required bool pinned, + required bool hideSubtasks, + required bool hideCompletedSubtasks, +}) { + final fields = {}; + if (capability.supportsDueTime && due != null && !scheduledAllDay) { + fields + ..['microsoftDueDateTime'] = _providerDateTime(due, timeZone) + ..['microsoftDueTimeZone'] = timeZone; + } + if (capability.supportsStartDateTime && start != null && !scheduledAllDay) { + fields + ..['microsoftStartDateTime'] = _providerDateTime(start, timeZone) + ..['microsoftStartTimeZone'] = timeZone; + } + if (capability.supportsReminderDateTime && + !capability.supportsMultipleReminders) { + fields['microsoftIsReminderOn'] = reminder != null; + if (reminder != null) { + fields + ..['microsoftReminderDateTime'] = _providerDateTime(reminder, timeZone) + ..['microsoftReminderTimeZone'] = timeZone; + } + } + if (capability.supportsMultipleReminders && alarms.isNotEmpty) { + fields['taskAlarms'] = [for (final alarm in alarms) alarm.toJson()]; + } + if (capability.supportsRecurrence && recurrence.repeats) { + fields['recurrence'] = provider == BusyProvider.nextcloud + ? jsonDecode(recurrence.toJsonString()) + : _microsoftTaskRecurrence(recurrence.frequency, start ?? due!); + } + if (capability.supportsImportance && !capability.supportsIcalPriority) { + fields['importance'] = importance; + } + if (capability.supportsTaskStatus && status.isNotEmpty) { + fields['taskStatus'] = status; + } + if (capability.supportsIcalPriority) fields['icalPriority'] = priority; + if (capability.supportsPercentComplete) fields['percentComplete'] = progress; + if (capability.supportsLocation) fields['location'] = location.trim(); + if (capability.supportsUrl) fields['taskUrl'] = taskUrl.trim(); + if (capability.supportsClassification) { + fields['taskClassification'] = classification; + } + if (capability.supportsPinning) fields['taskPinned'] = pinned; + if (capability.supportsSubtaskVisibility) { + fields + ..['taskHideSubtasks'] = hideSubtasks + ..['taskHideCompletedSubtasks'] = hideCompletedSubtasks; + } + return fields; +} + +Map _providerDateTime(DateTime value, String timeZone) => { + 'dateTime': value.toIso8601String(), + 'timeZone': timeZone, +}; + +Map _microsoftTaskRecurrence( + RecurrenceFrequency frequency, + DateTime baseDate, +) => { + 'pattern': switch (frequency) { + RecurrenceFrequency.daily => {'type': 'daily', 'interval': 1}, + RecurrenceFrequency.weekly => { + 'type': 'weekly', + 'interval': 1, + 'daysOfWeek': [_microsoftWeekday(baseDate.weekday)], + 'firstDayOfWeek': 'monday', + }, + RecurrenceFrequency.monthly => { + 'type': 'absoluteMonthly', + 'interval': 1, + 'dayOfMonth': baseDate.day, + }, + RecurrenceFrequency.yearly => { + 'type': 'absoluteYearly', + 'interval': 1, + 'dayOfMonth': baseDate.day, + 'month': baseDate.month, + }, + RecurrenceFrequency.none => {'type': 'daily', 'interval': 1}, + }, + 'range': {'type': 'noEnd', 'startDate': _dateOnly(baseDate)}, +}; + +String _microsoftWeekday(int weekday) => switch (weekday) { + DateTime.monday => 'monday', + DateTime.tuesday => 'tuesday', + DateTime.wednesday => 'wednesday', + DateTime.thursday => 'thursday', + DateTime.friday => 'friday', + DateTime.saturday => 'saturday', + _ => 'sunday', +}; + +String _dateOnly(DateTime value) => + '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; diff --git a/lib/src/ui/windows/windows_task_details_dialog.dart b/lib/src/ui/windows/windows_task_details_dialog.dart new file mode 100644 index 00000000..b4f0d70f --- /dev/null +++ b/lib/src/ui/windows/windows_task_details_dialog.dart @@ -0,0 +1,1583 @@ +import 'dart:convert'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../dav/ical/ical_task_alarm.dart'; +import '../../features/tasks/domain/task_capabilities.dart'; +import '../../features/tasks/data/tasks_repository.dart'; +import '../../features/tasks/presentation/task_details_draft.dart'; +import '../../features/schedule/presentation/schedule_item_exporter.dart'; +import '../../features/task_lists/data/task_lists_repository.dart'; +import '../../providers/busy_provider.dart'; +import '../../schedule/schedule_item.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; +import 'windows_time_zone_dialog.dart'; + +Future showWindowsTaskDetailsDialog( + BuildContext context, + WidgetRef ref, + TaskScheduleItem task, +) async { + final repository = ref.read( + tasksRepositoryForAccountProvider(task.accountId), + ); + final original = await repository.watchTask(task.sourceId, task.id).first; + final accounts = await ref + .read(accountsRepositoryProvider) + .watchAccounts() + .first; + final account = accounts + .where((value) => value.id == task.accountId) + .firstOrNull; + if (original == null || account == null || !context.mounted) return false; + var capabilities = switch (account.provider) { + BusyProvider.nextcloud => + await ref.read( + davTaskCollectionCapabilitiesProvider(( + accountId: task.accountId, + taskListId: task.sourceId, + )).future, + ) ?? + noTaskCollectionCapabilities, + BusyProvider.google || + BusyProvider.microsoft => adapterDefaultTaskCapabilities(account.provider), + BusyProvider.appleICloud || + BusyProvider.webCal => noTaskCollectionCapabilities, + }; + if (original.recurrenceIdKey != null && + !capabilities.supportsRecurringTaskOccurrenceEditing) { + capabilities = capabilities.asReadOnly(); + } + final taskLists = await ref + .read(taskListsRepositoryForAccountProvider(task.accountId)) + .listTaskLists(); + var hierarchy = capabilities.supportsTaskHierarchy + ? await repository.watchTaskHierarchy(task.sourceId, task.id).first + : const TaskHierarchySnapshot(parent: null, subtasks: []); + if (!context.mounted) return false; + final localTimeZone = ref.read(localTimeZoneProvider); + final originalDraft = TaskDetailsDraft.fromTask(original, localTimeZone); + final title = TextEditingController(text: original.title); + final notes = TextEditingController(text: original.notes); + final categories = TextEditingController( + text: originalDraft.categories.join(', '), + ); + final location = TextEditingController(text: originalDraft.location); + final taskUrl = TextEditingController(text: originalDraft.taskUrl); + final subtaskTitle = TextEditingController(); + var completed = task.completed; + var selectedTaskListId = original.taskListId; + var due = _taskDueDateTime(originalDraft); + var dueHasTime = originalDraft.microsoftDueTime != null; + var dueTimeZone = originalDraft.microsoftDueTimeZone ?? localTimeZone; + var start = _taskDateTime( + originalDraft.microsoftStartDate, + originalDraft.microsoftStartTime, + ); + var startHasTime = originalDraft.microsoftStartTime != null; + var startTimeZone = originalDraft.microsoftStartTimeZone ?? localTimeZone; + var reminder = _taskDateTime( + originalDraft.microsoftReminderDate, + originalDraft.microsoftReminderTime, + ); + var reminderEnabled = originalDraft.microsoftReminderEnabled; + var reminderTimeZone = + originalDraft.microsoftReminderTimeZone ?? localTimeZone; + var recurrenceType = _recurrenceType(originalDraft.recurrenceJson); + var recurrenceJson = originalDraft.recurrenceJson; + var importance = originalDraft.importance; + var priority = originalDraft.icalPriority; + var taskStatus = originalDraft.taskStatus ?? ''; + var progress = originalDraft.percentComplete; + var completedAt = _taskDateTime( + originalDraft.completedDate, + originalDraft.completedTime, + ); + var classification = originalDraft.classification; + var pinned = originalDraft.pinned; + var hideSubtasks = originalDraft.hideSubtasks; + var hideCompletedSubtasks = originalDraft.hideCompletedSubtasks; + var alarms = [...originalDraft.alarms]; + var busy = false; + var changed = false; + var allowPop = false; + TaskScheduleItem? nextTask; + String? error; + String? message; + TaskDetailsDraft currentDraft() => originalDraft.copyWith( + taskListId: selectedTaskListId, + title: title.text.trim(), + notes: notes.text, + dueDate: due == null ? null : _dateOnly(due!), + microsoftDueTime: + due == null || !capabilities.supportsDueTime || !dueHasTime + ? null + : _timeOnly(due!), + microsoftDueTimeZone: dueTimeZone, + microsoftStartDate: start == null ? null : _dateOnly(start!), + microsoftStartTime: start == null || !startHasTime + ? null + : _timeOnly(start!), + microsoftStartTimeZone: startTimeZone, + microsoftReminderEnabled: reminderEnabled, + microsoftReminderDate: !reminderEnabled || reminder == null + ? null + : _dateOnly(reminder!), + microsoftReminderTime: !reminderEnabled || reminder == null + ? null + : _timeOnly(reminder!), + microsoftReminderTimeZone: reminderTimeZone, + recurrenceJson: recurrenceJson, + importance: importance, + categories: _categories(categories.text), + icalPriority: priority, + percentComplete: progress, + taskStatus: taskStatus.isEmpty ? null : taskStatus, + completedDate: completedAt == null ? null : _dateOnly(completedAt!), + completedTime: completedAt == null ? null : _timeOnly(completedAt!), + location: location.text, + taskUrl: taskUrl.text.trim(), + classification: classification, + pinned: pinned, + hideSubtasks: hideSubtasks, + hideCompletedSubtasks: hideCompletedSubtasks, + alarms: alarms, + ); + bool hasPendingEdits() => + !currentDraft().hasSameValues(originalDraft) || + (!capabilities.supportsTaskStatus && completed != task.completed); + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + final enabled = !busy && capabilities.canUpdateTasks; + final provisionalDraft = currentDraft(); + final validUrl = + taskUrl.text.trim().isEmpty || + (Uri.tryParse(taskUrl.text.trim())?.hasScheme ?? false); + void closeDialog() { + setState(() => allowPop = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (dialogContext.mounted) Navigator.pop(dialogContext); + }); + } + + return PopScope( + canPop: allowPop || (!busy && !hasPendingEdits()), + onPopInvokedWithResult: (didPop, _) async { + if (didPop || busy || allowPop) return; + if (await _confirmDiscardTaskChanges(context) && context.mounted) { + closeDialog(); + } + }, + child: ContentDialog( + title: Text(l10n.editTask), + constraints: const BoxConstraints(maxWidth: 620, maxHeight: 760), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InfoLabel( + label: l10n.title, + child: TextBox( + controller: title, + enabled: enabled, + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(height: 12), + Checkbox( + checked: completed, + onChanged: busy || !capabilities.canUpdateTasks + ? null + : (value) => setState(() { + completed = value ?? false; + if (capabilities.supportsTaskStatus) { + taskStatus = completed + ? 'COMPLETED' + : 'NEEDS-ACTION'; + progress = completed ? 100 : 0; + completedAt = completed ? DateTime.now() : null; + } + }), + content: Text(l10n.completed), + ), + if (capabilities.supportsDueDate) ...[ + const SizedBox(height: 12), + _TaskDateTimeField( + label: l10n.dueDate, + value: due, + showTime: capabilities.supportsDueTime && dueHasTime, + timeZone: capabilities.supportsDueTime && dueHasTime + ? dueTimeZone + : null, + enabled: enabled, + onChanged: (value) => setState(() => due = value), + onChooseTimeZone: () async { + final value = await showWindowsTimeZoneDialog( + context, + selectedTimeZone: dueTimeZone, + ); + if (value != null) setState(() => dueTimeZone = value); + }, + ), + if (due != null && capabilities.supportsDueTime) ...[ + const SizedBox(height: 8), + ToggleSwitch( + checked: !dueHasTime, + onChanged: enabled + ? (value) => setState(() => dueHasTime = !value) + : null, + content: Text(l10n.allDay), + ), + ], + ], + if (taskLists.isNotEmpty) ...[ + const SizedBox(height: 12), + _TaskListCombo( + taskLists: taskLists, + selectedTaskListId: selectedTaskListId, + enabled: enabled && capabilities.supportsCrossListMove, + onChanged: (value) => + setState(() => selectedTaskListId = value), + ), + ], + if (capabilities.supportsStartDateTime) ...[ + const SizedBox(height: 12), + _TaskDateTimeField( + label: l10n.startDateTime, + value: start, + showTime: startHasTime, + timeZone: startHasTime ? startTimeZone : null, + enabled: enabled, + onChanged: (value) => setState(() => start = value), + onChooseTimeZone: () async { + final value = await showWindowsTimeZoneDialog( + context, + selectedTimeZone: startTimeZone, + ); + if (value != null) { + setState(() => startTimeZone = value); + } + }, + ), + if (start != null) ...[ + const SizedBox(height: 8), + ToggleSwitch( + checked: !startHasTime, + onChanged: enabled + ? (value) => setState(() => startHasTime = !value) + : null, + content: Text(l10n.allDay), + ), + ], + ], + if (capabilities.supportsReminderDateTime && + !capabilities.supportsMultipleReminders) ...[ + const SizedBox(height: 12), + ToggleSwitch( + checked: reminderEnabled, + onChanged: enabled + ? (value) => setState(() { + reminderEnabled = value; + reminder ??= (due ?? start ?? DateTime.now()) + .subtract(const Duration(minutes: 15)); + }) + : null, + content: Text(l10n.reminder), + ), + if (reminderEnabled) ...[ + const SizedBox(height: 8), + _TaskDateTimeField( + label: l10n.reminderTime, + value: reminder, + showTime: true, + timeZone: reminderTimeZone, + enabled: enabled, + onChanged: (value) => setState(() { + reminder = value; + if (value == null) reminderEnabled = false; + }), + onChooseTimeZone: () async { + final value = await showWindowsTimeZoneDialog( + context, + selectedTimeZone: reminderTimeZone, + ); + if (value != null) { + setState(() => reminderTimeZone = value); + } + }, + ), + ], + ], + if (capabilities.supportsRecurrence && + (due != null || start != null)) ...[ + const SizedBox(height: 12), + _StringCombo( + label: l10n.repeat, + value: recurrenceType, + values: { + 'none': l10n.repeatNone, + 'daily': l10n.repeatDaily, + 'weekly': l10n.repeatWeekly, + 'absoluteMonthly': l10n.repeatMonthly, + 'absoluteYearly': l10n.repeatYearly, + if (recurrenceType == 'unsupported') + 'unsupported': l10n.unsupportedRecurrencePreserved, + }, + enabled: enabled && recurrenceType != 'unsupported', + onChanged: (value) => setState(() { + recurrenceType = value; + recurrenceJson = _recurrenceJsonFor( + value, + due: due, + start: start, + dav: account.provider == BusyProvider.nextcloud, + ); + }), + ), + ], + if (capabilities.supportsImportance && + !capabilities.supportsIcalPriority) ...[ + const SizedBox(height: 12), + _StringCombo( + label: l10n.importance, + value: importance, + values: { + 'low': l10n.importanceLow, + 'normal': l10n.importanceNormal, + 'high': l10n.importanceHigh, + }, + enabled: enabled, + onChanged: (value) => setState(() => importance = value), + ), + ], + if (capabilities.supportsCategories) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.categories, + child: TextBox(controller: categories, enabled: enabled), + ), + ], + if (capabilities.supportsTaskStatus) ...[ + const SizedBox(height: 12), + _StringCombo( + label: l10n.taskStatus, + value: taskStatus, + values: { + '': l10n.taskStatusNone, + 'NEEDS-ACTION': l10n.taskStatusNeedsAction, + 'IN-PROCESS': l10n.taskStatusInProcess, + 'COMPLETED': l10n.taskStatusCompleted, + 'CANCELLED': l10n.taskStatusCancelled, + }, + enabled: enabled, + onChanged: (value) => setState(() { + taskStatus = value; + if (value == 'COMPLETED') { + progress = 100; + completed = true; + completedAt ??= DateTime.now(); + } else if (value == 'IN-PROCESS') { + if (progress == 0 || progress == 100) progress = 1; + completed = false; + completedAt = null; + } else if (value == 'NEEDS-ACTION' || value.isEmpty) { + if (progress == 100) progress = 0; + completed = false; + completedAt = null; + } + }), + ), + ], + if (capabilities.supportsPercentComplete) ...[ + const SizedBox(height: 12), + Text(l10n.completionPercent(progress)), + Slider( + value: progress.toDouble(), + max: 100, + divisions: 100, + onChanged: enabled + ? (value) => setState(() { + progress = value.round(); + if (progress == 100) { + taskStatus = 'COMPLETED'; + completed = true; + completedAt ??= DateTime.now(); + } else if (progress == 0) { + taskStatus = 'NEEDS-ACTION'; + completed = false; + completedAt = null; + } else { + taskStatus = 'IN-PROCESS'; + completed = false; + completedAt = null; + } + }) + : null, + ), + ], + if (capabilities.supportsIcalPriority) ...[ + const SizedBox(height: 12), + Text(_priorityLabel(l10n, priority)), + Slider( + value: priority.toDouble(), + max: 9, + divisions: 9, + onChanged: enabled + ? (value) => setState(() => priority = value.round()) + : null, + ), + ], + if (capabilities.supportsLocation) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.location, + child: TextBox(controller: location, enabled: enabled), + ), + ], + if (capabilities.supportsUrl) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.taskUrl, + child: TextBox( + controller: taskUrl, + enabled: enabled, + onChanged: (_) => setState(() {}), + ), + ), + if (!validUrl) + InfoBar( + title: Text(l10n.invalidTaskUrl), + severity: InfoBarSeverity.warning, + ), + ], + if (capabilities.supportsClassification) ...[ + const SizedBox(height: 12), + _StringCombo( + label: l10n.classification, + value: classification, + values: { + 'PUBLIC': l10n.classificationPublic, + 'CONFIDENTIAL': l10n.classificationConfidential, + 'PRIVATE': l10n.classificationPrivate, + }, + enabled: enabled && capabilities.canUpdateClassification, + onChanged: (value) => + setState(() => classification = value), + ), + ], + if (capabilities.supportsPinning) ...[ + const SizedBox(height: 12), + ToggleSwitch( + checked: pinned, + onChanged: enabled + ? (value) => setState(() => pinned = value) + : null, + content: Text(l10n.pinTask), + ), + ], + if (capabilities.supportsSubtaskVisibility) ...[ + const SizedBox(height: 8), + ToggleSwitch( + checked: hideSubtasks, + onChanged: enabled + ? (value) => setState(() => hideSubtasks = value) + : null, + content: Text(l10n.hideSubtasks), + ), + const SizedBox(height: 8), + ToggleSwitch( + checked: hideCompletedSubtasks, + onChanged: enabled + ? (value) => + setState(() => hideCompletedSubtasks = value) + : null, + content: Text(l10n.hideClosedSubtasks), + ), + ], + if (provisionalDraft.scheduleIssue != + TaskScheduleIssue.none) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text( + provisionalDraft.scheduleIssue == + TaskScheduleIssue.dueBeforeStart + ? l10n.taskDueBeforeStart + : l10n.taskStartDueTimeModeMismatch, + ), + severity: InfoBarSeverity.warning, + ), + ], + const SizedBox(height: 12), + InfoLabel( + label: l10n.notes, + child: TextBox( + controller: notes, + enabled: !busy && capabilities.canUpdateTasks, + maxLines: 5, + ), + ), + if (capabilities.supportsTaskHierarchy) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.subtasks, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (hierarchy.parent case final parent?) + ListTile( + leading: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.previous), + ), + title: Text(parent.title), + subtitle: Text(l10n.parent), + onPressed: busy + ? null + : () async { + if (hasPendingEdits() && + !await _confirmDiscardTaskChanges( + context, + )) { + return; + } + nextTask = _scheduleItemForTask( + parent, + account.provider, + ); + closeDialog(); + }, + ), + for (final subtask in hierarchy.subtasks) + ListTile( + leading: Checkbox( + checked: subtask.completed, + onChanged: enabled + ? (value) async { + setState(() => busy = true); + try { + if (subtask.kind == + TaskSubtaskKind.task) { + final child = subtask.task!; + await repository.patchTask( + child.taskListId, + child.id, + TaskPatchInput({ + 'status': value == true + ? 'completed' + : 'needsAction', + 'completed': value == true + ? DateTime.now() + .toUtc() + .toIso8601String() + : null, + }), + ); + } else { + await repository + .patchChecklistSubtask( + taskListId: + original.taskListId, + parentTaskId: original.id, + checklistItemId: subtask.id, + completed: value ?? false, + ); + } + hierarchy = await repository + .watchTaskHierarchy( + original.taskListId, + original.id, + ) + .first; + setState(() { + busy = false; + changed = true; + error = null; + }); + } on Object catch (_) { + setState(() { + busy = false; + error = l10n.operationFailed; + }); + } + } + : null, + ), + title: Text(subtask.title), + onPressed: + busy || subtask.kind != TaskSubtaskKind.task + ? null + : () async { + if (hasPendingEdits() && + !await _confirmDiscardTaskChanges( + context, + )) { + return; + } + nextTask = _scheduleItemForTask( + subtask.task!, + account.provider, + ); + closeDialog(); + }, + trailing: + subtask.kind == TaskSubtaskKind.checklistItem + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon( + windowsBusyMaxGlyph( + BusyMaxGlyph.edit, + ), + ), + onPressed: enabled + ? () async { + final value = + await _showTextInputDialog( + context, + title: l10n.rename, + initialValue: + subtask.title, + ); + if (value == null || + value == subtask.title) { + return; + } + setState(() => busy = true); + try { + await repository + .patchChecklistSubtask( + taskListId: original + .taskListId, + parentTaskId: + original.id, + checklistItemId: + subtask.id, + title: value, + ); + hierarchy = await repository + .watchTaskHierarchy( + original.taskListId, + original.id, + ) + .first; + setState(() { + busy = false; + changed = true; + error = null; + }); + } on Object catch (_) { + setState(() { + busy = false; + error = + l10n.operationFailed; + }); + } + } + : null, + ), + IconButton( + icon: Icon( + windowsBusyMaxGlyph( + BusyMaxGlyph.delete, + ), + ), + onPressed: enabled + ? () async { + setState(() => busy = true); + try { + await repository + .deleteChecklistSubtask( + taskListId: original + .taskListId, + parentTaskId: + original.id, + checklistItemId: + subtask.id, + ); + hierarchy = await repository + .watchTaskHierarchy( + original.taskListId, + original.id, + ) + .first; + setState(() { + busy = false; + changed = true; + error = null; + }); + } on Object catch (_) { + setState(() { + busy = false; + error = + l10n.operationFailed; + }); + } + } + : null, + ), + ], + ) + : null, + ), + const SizedBox(height: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextBox( + controller: subtaskTitle, + enabled: !busy && capabilities.canCreateTasks, + placeholder: l10n.createSubtask, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + Button( + onPressed: + busy || + !capabilities.canCreateTasks || + subtaskTitle.text.trim().isEmpty + ? null + : () async { + setState(() => busy = true); + try { + await repository.createSubtask( + taskListId: original.taskListId, + parentTaskId: original.id, + title: subtaskTitle.text, + ); + subtaskTitle.clear(); + hierarchy = await repository + .watchTaskHierarchy( + original.taskListId, + original.id, + ) + .first; + setState(() { + busy = false; + changed = true; + error = null; + }); + } on Object catch (_) { + setState(() { + busy = false; + error = l10n.operationFailed; + }); + } + }, + child: Text(l10n.createSubtask), + ), + ], + ), + ], + ), + ), + ], + if (capabilities.supportsTaskReparenting || + capabilities.supportsDuplicate || + capabilities.supportsNativeExport) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (capabilities.supportsTaskReparenting) + Button( + onPressed: enabled + ? () async { + setState(() => busy = true); + try { + await repository.moveTask( + TaskMoveInput( + sourceTaskListId: original.taskListId, + taskId: original.id, + ), + ); + hierarchy = await repository + .watchTaskHierarchy( + original.taskListId, + original.id, + ) + .first; + setState(() { + busy = false; + changed = true; + error = null; + }); + } on Object catch (_) { + setState(() { + busy = false; + error = l10n.operationFailed; + }); + } + } + : null, + child: Text(l10n.moveToTop), + ), + if (capabilities.supportsDuplicate) + Button( + onPressed: !busy && capabilities.canCreateTasks + ? () async { + setState(() => busy = true); + try { + await repository.duplicateTask( + original.taskListId, + original.id, + ); + setState(() { + busy = false; + changed = true; + error = null; + message = l10n.taskDuplicated; + }); + } on Object catch (_) { + setState(() { + busy = false; + error = l10n.operationFailed; + }); + } + } + : null, + child: Text(l10n.duplicateTask), + ), + if (capabilities.supportsNativeExport) + Button( + onPressed: busy + ? null + : () async { + try { + final raw = await repository + .nativeTaskExport( + original.taskListId, + original.id, + ); + if (raw == null) { + throw StateError( + 'Native iCalendar data unavailable.', + ); + } + final file = + await exportICalendarWithSaveDialog( + suggestedName: taskExportFileName( + title: original.title, + dueDate: original.dueUtc, + ), + calendarData: raw, + ); + if (file != null) { + setState(() { + error = null; + message = l10n.exportedFile( + file.path, + ); + }); + } + } on Object catch (_) { + setState(() { + error = l10n.operationFailed; + }); + } + }, + child: Text(l10n.export), + ), + ], + ), + ], + if (message != null) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text(message!), + severity: InfoBarSeverity.success, + ), + ], + if (error != null) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text(error!), + severity: InfoBarSeverity.error, + ), + ], + if (capabilities.supportsMultipleReminders) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.reminder, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < alarms.length; index += 1) + ListTile( + title: Text(_alarmLabel(context, alarms[index])), + subtitle: alarms[index].absoluteUtc == null + ? Text(l10n.unsupportedReminder) + : null, + onPressed: + !enabled || alarms[index].absoluteUtc == null + ? null + : () async { + final value = + await _showAbsoluteReminderDialog( + context, + initial: alarms[index].absoluteUtc! + .toLocal(), + ); + if (value != null) { + setState(() { + alarms[index] = alarms[index] + .withAbsoluteTrigger( + value.toUtc(), + ); + }); + } + }, + trailing: IconButton( + icon: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.delete), + ), + onPressed: enabled + ? () => + setState(() => alarms.removeAt(index)) + : null, + ), + ), + Button( + onPressed: enabled + ? () async { + final value = + await _showAbsoluteReminderDialog( + context, + initial: + (due ?? start ?? DateTime.now()) + .subtract( + const Duration(minutes: 15), + ), + ); + if (value != null) { + setState(() { + alarms = [ + ...alarms, + IcalTaskAlarm.displayAbsolute( + value.toUtc(), + ), + ]; + }); + } + } + : null, + child: Text(l10n.addReminder), + ), + ], + ), + ), + ], + ], + ), + ), + actions: [ + Button( + onPressed: busy || !capabilities.canDeleteTasks + ? null + : () async { + final confirmed = await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(l10n.deleteTask), + content: Text( + l10n.deleteTaskConfirmation(task.title), + ), + actions: [ + Button( + onPressed: () => Navigator.pop(context, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.delete), + ), + ], + ), + ); + if (!(confirmed ?? false)) return; + setState(() => busy = true); + try { + await repository.deleteTask(task.sourceId, task.id); + changed = true; + closeDialog(); + } on Object catch (_) { + setState(() { + busy = false; + error = l10n.operationFailed; + }); + } + }, + child: Text(l10n.delete), + ), + Button( + onPressed: busy + ? null + : () async { + if (hasPendingEdits() && + !await _confirmDiscardTaskChanges(context)) { + return; + } + closeDialog(); + }, + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: + busy || + !capabilities.canUpdateTasks || + title.text.trim().isEmpty || + !validUrl || + provisionalDraft.scheduleIssue != TaskScheduleIssue.none + ? null + : () async { + setState(() => busy = true); + try { + final updatedDraft = currentDraft(); + if (updatedDraft.scheduleIssue != + TaskScheduleIssue.none) { + setState(() { + busy = false; + error = switch (updatedDraft.scheduleIssue) { + TaskScheduleIssue.dueBeforeStart => + l10n.taskDueBeforeStart, + TaskScheduleIssue.mixedTimeModes => + l10n.taskStartDueTimeModeMismatch, + TaskScheduleIssue.none => null, + }; + }); + return; + } + final patch = updatedDraft.toPatch( + original, + capabilities, + localTimeZone: localTimeZone, + ); + if (!capabilities.supportsTaskStatus && + completed != task.completed) { + patch + ..['status'] = completed + ? 'completed' + : 'needsAction' + ..['completed'] = completed + ? DateTime.now().toUtc().toIso8601String() + : null; + } + if (patch.isNotEmpty) { + await repository.patchTask( + task.sourceId, + task.id, + TaskPatchInput(patch), + ); + } + if (selectedTaskListId != original.taskListId) { + await repository.moveTask( + TaskMoveInput( + sourceTaskListId: original.taskListId, + taskId: original.id, + destinationTaskListId: selectedTaskListId, + ), + ); + } + changed = true; + closeDialog(); + } on Object catch (_) { + setState(() { + busy = false; + error = l10n.operationFailed; + }); + } + }, + child: busy + ? const SizedBox( + width: 16, + height: 16, + child: ProgressRing(), + ) + : Text(l10n.save), + ), + ], + ), + ); + }, + ), + ); + title.dispose(); + notes.dispose(); + categories.dispose(); + location.dispose(); + taskUrl.dispose(); + subtaskTitle.dispose(); + if (nextTask case final taskToOpen?) { + if (!context.mounted) return changed; + return await showWindowsTaskDetailsDialog(context, ref, taskToOpen) || + changed; + } + return changed; +} + +TaskScheduleItem _scheduleItemForTask( + TaskEntity task, + BusyProvider provider, +) => TaskScheduleItem( + id: task.id, + accountId: task.accountId, + provider: provider, + sourceId: task.taskListId, + title: task.title, + completed: task.status == 'completed' || task.status == 'COMPLETED', + // The dialog reloads the provider-native schedule fields before rendering. + allDay: true, +); + +class _TaskListCombo extends StatelessWidget { + const _TaskListCombo({ + required this.taskLists, + required this.selectedTaskListId, + required this.enabled, + required this.onChanged, + }); + + final List taskLists; + final String selectedTaskListId; + final bool enabled; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => InfoLabel( + label: AppLocalizations.of(context).list, + child: ComboBox( + isExpanded: true, + value: selectedTaskListId, + items: [ + for (final taskList in taskLists) + ComboBoxItem(value: taskList.id, child: Text(taskList.title)), + ], + onChanged: enabled + ? (value) { + if (value != null) onChanged(value); + } + : null, + ), + ); +} + +String _dateOnly(DateTime value) => + '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; + +String _timeOnly(DateTime value) => + '${value.hour.toString().padLeft(2, '0')}:' + '${value.minute.toString().padLeft(2, '0')}'; + +DateTime? _taskDueDateTime(TaskDetailsDraft draft) { + return _taskDateTime(draft.dueDate, draft.microsoftDueTime); +} + +DateTime? _taskDateTime(String? dateValue, String? timeValue) { + final date = DateTime.tryParse(dateValue ?? ''); + if (date == null) return null; + final parts = timeValue?.split(':'); + return DateTime( + date.year, + date.month, + date.day, + parts == null || parts.isEmpty ? 0 : int.tryParse(parts[0]) ?? 0, + parts == null || parts.length < 2 ? 0 : int.tryParse(parts[1]) ?? 0, + ); +} + +class _TaskDateTimeField extends StatelessWidget { + const _TaskDateTimeField({ + required this.label, + required this.value, + required this.showTime, + required this.timeZone, + required this.enabled, + required this.onChanged, + required this.onChooseTimeZone, + }); + + final String label; + final DateTime? value; + final bool showTime; + final String? timeZone; + final bool enabled; + final ValueChanged onChanged; + final Future Function() onChooseTimeZone; + + @override + Widget build(BuildContext context) => InfoLabel( + label: label, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + DatePicker( + selected: value, + onChanged: enabled + ? (date) => onChanged( + DateTime( + date.year, + date.month, + date.day, + value?.hour ?? 9, + value?.minute ?? 0, + ), + ) + : null, + ), + if (showTime && value != null) ...[ + TimePicker( + selected: value, + onChanged: enabled + ? (time) => onChanged( + DateTime( + value!.year, + value!.month, + value!.day, + time.hour, + time.minute, + ), + ) + : null, + ), + if (timeZone != null) + Button( + onPressed: enabled ? onChooseTimeZone : null, + child: Text(timeZone!), + ), + ], + if (value != null) + IconButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.close)), + onPressed: enabled ? () => onChanged(null) : null, + ), + ], + ), + ); +} + +class _StringCombo extends StatelessWidget { + const _StringCombo({ + required this.label, + required this.value, + required this.values, + required this.enabled, + required this.onChanged, + }); + + final String label; + final String value; + final Map values; + final bool enabled; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => InfoLabel( + label: label, + child: ComboBox( + isExpanded: true, + value: value, + items: [ + for (final entry in values.entries) + ComboBoxItem(value: entry.key, child: Text(entry.value)), + ], + onChanged: enabled + ? (value) { + if (value != null) onChanged(value); + } + : null, + ), + ); +} + +String _alarmLabel(BuildContext context, IcalTaskAlarm alarm) { + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + final absolute = alarm.absoluteUtc; + if (absolute != null) { + final local = absolute.toLocal(); + return l10n.dateTimeDisplay( + DateFormat.yMMMd(locale).format(local), + DateFormat.jm(locale).format(local), + ); + } + final offset = alarm.relativeOffset; + if (offset == Duration.zero) { + return alarm.isRelatedToDue + ? l10n.reminderAtTaskDue + : l10n.reminderAtTaskStart; + } + if (offset != null && offset.isNegative) { + final minutes = offset.inMinutes.abs(); + const minutesPerDay = Duration.minutesPerHour * Duration.hoursPerDay; + if (minutes % minutesPerDay == 0) { + return l10n.reminderDaysBefore(minutes ~/ minutesPerDay); + } + if (minutes % Duration.minutesPerHour == 0) { + return l10n.reminderHoursBefore(minutes ~/ Duration.minutesPerHour); + } + return l10n.reminderMinutesBefore(minutes); + } + return l10n.unsupportedReminder; +} + +Future _showAbsoluteReminderDialog( + BuildContext context, { + required DateTime initial, +}) { + var value = initial; + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) => ContentDialog( + title: Text(AppLocalizations.of(context).reminderTime), + content: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + DatePicker( + selected: value, + onChanged: (date) => setState(() { + value = DateTime( + date.year, + date.month, + date.day, + value.hour, + value.minute, + ); + }), + ), + TimePicker( + selected: value, + onChanged: (time) => setState(() { + value = DateTime( + value.year, + value.month, + value.day, + time.hour, + time.minute, + ); + }), + ), + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(context).cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, value), + child: Text(AppLocalizations.of(context).save), + ), + ], + ), + ), + ); +} + +Future _showTextInputDialog( + BuildContext context, { + required String title, + required String initialValue, +}) async { + final controller = TextEditingController(text: initialValue); + String? result; + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) => ContentDialog( + title: Text(title), + content: TextBox( + controller: controller, + autofocus: true, + onChanged: (_) => setState(() {}), + onSubmitted: (value) { + final normalized = value.trim(); + if (normalized.isEmpty) return; + result = normalized; + Navigator.pop(dialogContext); + }, + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(context).cancel), + ), + FilledButton( + onPressed: controller.text.trim().isEmpty + ? null + : () { + result = controller.text.trim(); + Navigator.pop(dialogContext); + }, + child: Text(AppLocalizations.of(context).save), + ), + ], + ), + ), + ); + controller.dispose(); + return result; +} + +Future _confirmDiscardTaskChanges(BuildContext context) async { + final l10n = AppLocalizations.of(context); + return await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.discardChanges), + content: Text(l10n.discardChangesConfirmation), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.discardChangesAction), + ), + ], + ), + ) ?? + false; +} + +String _recurrenceType(String? recurrenceJson) { + if (recurrenceJson == null || recurrenceJson.isEmpty) return 'none'; + try { + final decoded = jsonDecode(recurrenceJson); + if (decoded is Map) { + final pattern = decoded['pattern']; + if (pattern is Map) { + return switch (pattern['type']) { + 'daily' => 'daily', + 'weekly' => 'weekly', + 'absoluteMonthly' => 'absoluteMonthly', + 'absoluteYearly' => 'absoluteYearly', + _ => 'unsupported', + }; + } + final rules = decoded['rules']; + if (rules is List && rules.isNotEmpty) { + final rule = rules.first.toString().toUpperCase(); + if (rule.contains('FREQ=DAILY')) return 'daily'; + if (rule.contains('FREQ=WEEKLY')) return 'weekly'; + if (rule.contains('FREQ=MONTHLY')) return 'absoluteMonthly'; + if (rule.contains('FREQ=YEARLY')) return 'absoluteYearly'; + } + } + } on FormatException { + return 'unsupported'; + } + return 'unsupported'; +} + +String? _recurrenceJsonFor( + String type, { + required DateTime? due, + required DateTime? start, + required bool dav, +}) { + if (type == 'none') return null; + final recurrenceStart = start ?? due ?? DateTime.now(); + if (dav) { + final frequency = switch (type) { + 'daily' => 'DAILY', + 'weekly' => 'WEEKLY', + 'absoluteMonthly' => 'MONTHLY', + 'absoluteYearly' => 'YEARLY', + _ => 'DAILY', + }; + final parts = ['FREQ=$frequency', 'INTERVAL=1']; + if (type == 'weekly') { + parts.add('BYDAY=${_weekdayCode(recurrenceStart.weekday)}'); + } else if (type == 'absoluteMonthly') { + parts.add('BYMONTHDAY=${recurrenceStart.day}'); + } else if (type == 'absoluteYearly') { + parts + ..add('BYMONTH=${recurrenceStart.month}') + ..add('BYMONTHDAY=${recurrenceStart.day}'); + } + return jsonEncode({ + 'rules': [parts.join(';')], + 'dates': const [], + 'excludedDates': const [], + }); + } + final pattern = switch (type) { + 'daily' => {'type': 'daily', 'interval': 1}, + 'weekly' => { + 'type': 'weekly', + 'interval': 1, + 'daysOfWeek': [_weekdayName(recurrenceStart.weekday)], + 'firstDayOfWeek': 'monday', + }, + 'absoluteMonthly' => { + 'type': 'absoluteMonthly', + 'interval': 1, + 'dayOfMonth': recurrenceStart.day, + }, + 'absoluteYearly' => { + 'type': 'absoluteYearly', + 'interval': 1, + 'dayOfMonth': recurrenceStart.day, + 'month': recurrenceStart.month, + }, + _ => {'type': 'daily', 'interval': 1}, + }; + return jsonEncode({ + 'pattern': pattern, + 'range': {'type': 'noEnd', 'startDate': _dateOnly(recurrenceStart)}, + }); +} + +String _weekdayCode(int weekday) => switch (weekday) { + DateTime.monday => 'MO', + DateTime.tuesday => 'TU', + DateTime.wednesday => 'WE', + DateTime.thursday => 'TH', + DateTime.friday => 'FR', + DateTime.saturday => 'SA', + _ => 'SU', +}; + +String _weekdayName(int weekday) => switch (weekday) { + DateTime.monday => 'monday', + DateTime.tuesday => 'tuesday', + DateTime.wednesday => 'wednesday', + DateTime.thursday => 'thursday', + DateTime.friday => 'friday', + DateTime.saturday => 'saturday', + _ => 'sunday', +}; + +String _priorityLabel(AppLocalizations l10n, int priority) => priority == 0 + ? l10n.priorityNone + : priority <= 4 + ? l10n.priorityHighValue(priority) + : priority == 5 + ? l10n.priorityMediumValue(priority) + : l10n.priorityLowValue(priority); + +List _categories(String value) => value + .split(',') + .map((category) => category.trim()) + .where((category) => category.isNotEmpty) + .toSet() + .toList(growable: false); diff --git a/lib/src/ui/windows/windows_task_editor_dialog.dart b/lib/src/ui/windows/windows_task_editor_dialog.dart new file mode 100644 index 00000000..8f6f603f --- /dev/null +++ b/lib/src/ui/windows/windows_task_editor_dialog.dart @@ -0,0 +1,885 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../dav/ical/ical_task_alarm.dart'; +import '../../features/recurrence/domain/event_recurrence_codec.dart'; +import '../../features/recurrence/domain/recurrence_rule.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 '../../providers/busy_provider.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; +import 'windows_recurrence_dialog.dart'; +import 'windows_task_create_fields.dart'; +import 'windows_time_zone_dialog.dart'; + +Future showWindowsTaskEditorDialog( + BuildContext context, + WidgetRef ref, +) async { + final accounts = await ref + .read(accountsRepositoryProvider) + .watchAccounts() + .first; + final accountsById = {for (final account in accounts) account.id: account}; + final capabilities = {}; + final lists = []; + for (final account in accounts.where((account) => account.isTaskCapable)) { + final accountLists = await ref + .read(taskListsRepositoryForAccountProvider(account.id)) + .listTaskLists(); + for (final list in accountLists) { + final value = account.provider == BusyProvider.nextcloud + ? await ref.read( + davTaskCollectionCapabilitiesProvider(( + accountId: account.id, + taskListId: list.id, + )).future, + ) ?? + noTaskCollectionCapabilities + : adapterDefaultTaskCapabilities(account.provider); + if (value.canCreateTasks) { + lists.add(list); + capabilities[list.id] = value; + } + } + } + if (!context.mounted) return false; + if (lists.isEmpty) { + await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(AppLocalizations.of(context).newTask), + content: Text(AppLocalizations.of(context).noTaskListsSynced), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context).close), + ), + ], + ), + ); + return false; + } + + final title = TextEditingController(); + final notes = TextEditingController(); + final location = TextEditingController(); + final taskUrl = TextEditingController(); + final categories = TextEditingController(); + var selectedList = lists.first; + var selectedTimeZone = ref.read(localTimeZoneProvider); + DateTime? due; + DateTime? start; + var scheduledAllDay = true; + DateTime? reminder; + var alarms = []; + var recurrence = const RecurrenceRule.none(); + var importance = 'normal'; + var status = ''; + var priority = 0; + var progress = 0; + var classification = 'PUBLIC'; + var pinned = false; + var hideSubtasks = false; + var hideCompletedSubtasks = false; + var saving = false; + var saved = false; + var allowPop = false; + String? error; + final initialListId = selectedList.id; + final initialTimeZone = selectedTimeZone; + bool hasPendingEdits() => + selectedList.id != initialListId || + selectedTimeZone != initialTimeZone || + title.text.isNotEmpty || + notes.text.isNotEmpty || + location.text.isNotEmpty || + taskUrl.text.isNotEmpty || + categories.text.isNotEmpty || + due != null || + start != null || + !scheduledAllDay || + reminder != null || + alarms.isNotEmpty || + recurrence.repeats || + importance != 'normal' || + status.isNotEmpty || + priority != 0 || + progress != 0 || + classification != 'PUBLIC' || + pinned || + hideSubtasks || + hideCompletedSubtasks; + + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + final capability = + capabilities[selectedList.id] ?? noTaskCollectionCapabilities; + final provider = + accountsById[selectedList.accountId]?.provider ?? + BusyProvider.google; + final validUrl = + taskUrl.text.trim().isEmpty || + (Uri.tryParse(taskUrl.text.trim())?.hasScheme ?? false); + final validSchedule = + due == null || start == null || !due!.isBefore(start!); + void closeDialog() { + setState(() => allowPop = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (dialogContext.mounted) Navigator.pop(dialogContext); + }); + } + + return PopScope( + canPop: allowPop || (!saving && !hasPendingEdits()), + onPopInvokedWithResult: (didPop, _) async { + if (didPop || saving || allowPop) return; + if (await _confirmDiscardNewTaskChanges(context) && + context.mounted) { + closeDialog(); + } + }, + child: ContentDialog( + title: Text(l10n.newTask), + constraints: const BoxConstraints(maxWidth: 620, maxHeight: 760), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InfoLabel( + label: l10n.title, + child: TextBox( + controller: title, + autofocus: true, + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.taskLists, + child: ComboBox( + isExpanded: true, + value: selectedList, + items: [ + for (final list in lists) + ComboBoxItem(value: list, child: Text(list.title)), + ], + onChanged: saving + ? null + : (list) { + if (list != null) { + setState(() { + selectedList = list; + recurrence = const RecurrenceRule.none(); + reminder = null; + alarms = []; + scheduledAllDay = true; + }); + } + }, + ), + ), + if (capability.supportsDueDate) ...[ + const SizedBox(height: 12), + _DateTimeField( + label: l10n.dueDate, + value: due, + showTime: capability.supportsDueTime && !scheduledAllDay, + enabled: !saving, + onChanged: (value) => setState(() => due = value), + ), + ], + if (capability.supportsStartDateTime) ...[ + const SizedBox(height: 12), + _DateTimeField( + label: l10n.startDateTime, + value: start, + showTime: !scheduledAllDay, + enabled: !saving, + onChanged: (value) => setState(() => start = value), + ), + ], + if ((due != null || start != null) && + (capability.supportsDueTime || + capability.supportsStartDateTime)) ...[ + const SizedBox(height: 8), + ToggleSwitch( + checked: scheduledAllDay, + onChanged: saving + ? null + : (value) => setState(() => scheduledAllDay = value), + content: Text(l10n.allDay), + ), + ], + if (capability.supportsDueTime || + capability.supportsStartDateTime || + capability.supportsReminderDateTime) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.selectTimeZone, + child: Button( + onPressed: saving + ? null + : () async { + final value = await showWindowsTimeZoneDialog( + context, + selectedTimeZone: selectedTimeZone, + ); + if (value != null) { + setState(() => selectedTimeZone = value); + } + }, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text(selectedTimeZone), + ), + ), + ), + ], + if (capability.supportsReminderDateTime && + !capability.supportsMultipleReminders) ...[ + const SizedBox(height: 12), + ToggleSwitch( + checked: reminder != null, + onChanged: saving + ? null + : (enabled) => setState( + () => reminder = enabled + ? (due ?? start ?? DateTime.now()).subtract( + const Duration(minutes: 15), + ) + : null, + ), + content: Text(l10n.reminder), + ), + if (reminder != null) ...[ + const SizedBox(height: 8), + _DateTimeField( + label: l10n.reminderTime, + value: reminder, + showTime: true, + enabled: !saving, + onChanged: (value) => setState(() => reminder = value), + ), + ], + ], + if (capability.supportsMultipleReminders) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.reminder, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < alarms.length; index += 1) + ListTile( + title: Text( + _newTaskAlarmLabel(context, alarms[index]), + ), + onPressed: saving + ? null + : () async { + final value = + await _showTaskReminderDialog( + context, + initial: alarms[index].absoluteUtc! + .toLocal(), + ); + if (value != null) { + setState(() { + alarms[index] = alarms[index] + .withAbsoluteTrigger( + value.toUtc(), + ); + }); + } + }, + trailing: IconButton( + icon: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.delete), + ), + onPressed: saving + ? null + : () => setState( + () => alarms.removeAt(index), + ), + ), + ), + Button( + onPressed: saving + ? null + : () async { + final value = await _showTaskReminderDialog( + context, + initial: (due ?? start ?? DateTime.now()) + .subtract( + const Duration(minutes: 15), + ), + ); + if (value != null) { + setState(() { + alarms = [ + ...alarms, + IcalTaskAlarm.displayAbsolute( + value.toUtc(), + ), + ]; + }); + } + }, + child: Text(l10n.addReminder), + ), + ], + ), + ), + ], + if (capability.supportsRecurrence && + (due != null || start != null)) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.repeat, + child: capability.supportsAdvancedRecurrence + ? Button( + onPressed: saving + ? null + : () async { + final value = + await showWindowsRecurrenceDialog( + context, + initial: recurrence, + baseDate: start ?? due!, + allDay: scheduledAllDay, + timeZone: selectedTimeZone, + providerLabel: provider.displayName, + limits: + RecurrenceRuleLimits.rfc5545, + ); + if (value != null) { + setState(() => recurrence = value); + } + }, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + _recurrenceLabel(l10n, recurrence.frequency), + ), + ), + ) + : ComboBox( + isExpanded: true, + value: recurrence.frequency, + items: [ + for (final frequency + in RecurrenceFrequency.values) + ComboBoxItem( + value: frequency, + child: Text( + _recurrenceLabel(l10n, frequency), + ), + ), + ], + onChanged: saving + ? null + : (frequency) { + if (frequency != null) { + setState( + () => recurrence = + _simpleRecurrenceRule( + frequency, + start ?? due!, + ), + ); + } + }, + ), + ), + ], + if (capability.supportsImportance && + !capability.supportsIcalPriority) ...[ + const SizedBox(height: 12), + _StringCombo( + label: l10n.importance, + value: importance, + values: { + 'low': l10n.importanceLow, + 'normal': l10n.importanceNormal, + 'high': l10n.importanceHigh, + }, + enabled: !saving, + onChanged: (value) => setState(() => importance = value), + ), + ], + if (capability.supportsCategories) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.categories, + child: TextBox(controller: categories), + ), + ], + if (capability.supportsTaskStatus) ...[ + const SizedBox(height: 12), + _StringCombo( + label: l10n.taskStatus, + value: status, + values: { + '': l10n.taskStatusNone, + 'NEEDS-ACTION': l10n.taskStatusNeedsAction, + 'IN-PROCESS': l10n.taskStatusInProcess, + 'COMPLETED': l10n.taskStatusCompleted, + 'CANCELLED': l10n.taskStatusCancelled, + }, + enabled: !saving, + onChanged: (value) => setState(() => status = value), + ), + ], + if (capability.supportsPercentComplete) ...[ + const SizedBox(height: 12), + Text(l10n.completionPercent(progress)), + Slider( + value: progress.toDouble(), + max: 100, + divisions: 100, + onChanged: saving + ? null + : (value) => setState(() => progress = value.round()), + ), + ], + if (capability.supportsIcalPriority) ...[ + const SizedBox(height: 12), + Text(_priorityLabel(l10n, priority)), + Slider( + value: priority.toDouble(), + max: 9, + divisions: 9, + onChanged: saving + ? null + : (value) => setState(() => priority = value.round()), + ), + ], + if (capability.supportsLocation) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.location, + child: TextBox(controller: location), + ), + ], + if (capability.supportsUrl) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.taskUrl, + child: TextBox( + controller: taskUrl, + onChanged: (_) => setState(() {}), + ), + ), + if (!validUrl) + InfoBar( + title: Text(l10n.invalidTaskUrl), + severity: InfoBarSeverity.warning, + ), + if (!validSchedule) ...[ + const SizedBox(height: 8), + InfoBar( + title: Text(l10n.taskDueBeforeStart), + severity: InfoBarSeverity.warning, + ), + ], + ], + if (capability.supportsClassification) ...[ + const SizedBox(height: 12), + _StringCombo( + label: l10n.classification, + value: classification, + values: { + 'PUBLIC': l10n.classificationPublic, + 'CONFIDENTIAL': l10n.classificationConfidential, + 'PRIVATE': l10n.classificationPrivate, + }, + enabled: !saving, + onChanged: (value) => + setState(() => classification = value), + ), + ], + if (capability.supportsPinning) ...[ + const SizedBox(height: 12), + ToggleSwitch( + checked: pinned, + onChanged: saving + ? null + : (value) => setState(() => pinned = value), + content: Text(l10n.pinTask), + ), + ], + if (capability.supportsSubtaskVisibility) ...[ + const SizedBox(height: 8), + ToggleSwitch( + checked: hideSubtasks, + onChanged: saving + ? null + : (value) => setState(() => hideSubtasks = value), + content: Text(l10n.hideSubtasks), + ), + const SizedBox(height: 8), + ToggleSwitch( + checked: hideCompletedSubtasks, + onChanged: saving + ? null + : (value) => + setState(() => hideCompletedSubtasks = value), + content: Text(l10n.hideClosedSubtasks), + ), + ], + const SizedBox(height: 12), + InfoLabel( + label: l10n.notes, + child: TextBox(controller: notes, maxLines: 4), + ), + if (error != null) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text(error!), + severity: InfoBarSeverity.error, + ), + ], + ], + ), + ), + actions: [ + Button( + onPressed: saving + ? null + : () async { + if (hasPendingEdits() && + !await _confirmDiscardNewTaskChanges(context)) { + return; + } + closeDialog(); + }, + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: + saving || + title.text.trim().isEmpty || + !validUrl || + !validSchedule + ? null + : () async { + setState(() { + saving = true; + error = null; + }); + try { + final fields = buildWindowsTaskCreateFields( + capability: capability, + provider: provider, + due: due, + start: start, + reminder: reminder, + alarms: alarms, + scheduledAllDay: scheduledAllDay, + timeZone: selectedTimeZone, + recurrence: recurrence, + importance: importance, + status: status, + priority: priority, + progress: progress, + location: location.text, + taskUrl: taskUrl.text, + classification: classification, + pinned: pinned, + hideSubtasks: hideSubtasks, + hideCompletedSubtasks: hideCompletedSubtasks, + ); + await ref + .read( + tasksRepositoryForAccountProvider( + selectedList.accountId, + ), + ) + .createTask( + selectedList.id, + TaskCreateInput( + title: title.text.trim(), + notes: notes.text.trim(), + dueUtc: due?.toUtc(), + categories: _categories(categories.text), + fields: fields, + ), + ); + saved = true; + closeDialog(); + } on Object catch (_) { + setState(() { + saving = false; + error = l10n.operationFailed; + }); + } + }, + child: saving + ? const SizedBox( + width: 16, + height: 16, + child: ProgressRing(), + ) + : Text(l10n.create), + ), + ], + ), + ); + }, + ), + ); + title.dispose(); + notes.dispose(); + location.dispose(); + taskUrl.dispose(); + categories.dispose(); + return saved; +} + +class _DateTimeField extends StatelessWidget { + const _DateTimeField({ + required this.label, + required this.value, + required this.showTime, + required this.enabled, + required this.onChanged, + }); + + final String label; + final DateTime? value; + final bool showTime; + final bool enabled; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => InfoLabel( + label: label, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + DatePicker( + selected: value, + onChanged: enabled + ? (date) => onChanged( + DateTime( + date.year, + date.month, + date.day, + value?.hour ?? 9, + value?.minute ?? 0, + ), + ) + : null, + ), + if (showTime && value != null) + TimePicker( + selected: value, + onChanged: enabled + ? (time) => onChanged( + DateTime( + value!.year, + value!.month, + value!.day, + time.hour, + time.minute, + ), + ) + : null, + ), + if (value != null) + IconButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.close)), + onPressed: enabled ? () => onChanged(null) : null, + ), + ], + ), + ); +} + +class _StringCombo extends StatelessWidget { + const _StringCombo({ + required this.label, + required this.value, + required this.values, + required this.enabled, + required this.onChanged, + }); + + final String label; + final String value; + final Map values; + final bool enabled; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => InfoLabel( + label: label, + child: ComboBox( + isExpanded: true, + value: value, + items: [ + for (final entry in values.entries) + ComboBoxItem(value: entry.key, child: Text(entry.value)), + ], + onChanged: enabled + ? (value) { + if (value != null) onChanged(value); + } + : null, + ), + ); +} + +String _newTaskAlarmLabel(BuildContext context, IcalTaskAlarm alarm) { + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + final local = alarm.absoluteUtc!.toLocal(); + return l10n.dateTimeDisplay( + DateFormat.yMMMd(locale).format(local), + DateFormat.jm(locale).format(local), + ); +} + +Future _showTaskReminderDialog( + BuildContext context, { + required DateTime initial, +}) { + var value = initial; + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) => ContentDialog( + title: Text(AppLocalizations.of(context).reminderTime), + content: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + DatePicker( + selected: value, + onChanged: (date) => setState(() { + value = DateTime( + date.year, + date.month, + date.day, + value.hour, + value.minute, + ); + }), + ), + TimePicker( + selected: value, + onChanged: (time) => setState(() { + value = DateTime( + value.year, + value.month, + value.day, + time.hour, + time.minute, + ); + }), + ), + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(context).cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, value), + child: Text(AppLocalizations.of(context).save), + ), + ], + ), + ), + ); +} + +Future _confirmDiscardNewTaskChanges(BuildContext context) async { + final l10n = AppLocalizations.of(context); + return await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.discardChanges), + content: Text(l10n.discardChangesConfirmation), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.discardChangesAction), + ), + ], + ), + ) ?? + false; +} + +RecurrenceRule _simpleRecurrenceRule( + RecurrenceFrequency frequency, + DateTime baseDate, +) { + if (frequency == RecurrenceFrequency.none) { + return const RecurrenceRule.none(); + } + return RecurrenceRule( + frequency: frequency, + interval: 1, + byDay: frequency == RecurrenceFrequency.weekly + ? [rfcWeekdays[baseDate.weekday - 1]] + : const [], + byMonth: frequency == RecurrenceFrequency.yearly + ? [baseDate.month] + : const [], + byMonthDay: + frequency == RecurrenceFrequency.monthly || + frequency == RecurrenceFrequency.yearly + ? [baseDate.day] + : const [], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: const [], + exceptionDates: const [], + rawRules: const [], + isSupported: true, + ); +} + +String _recurrenceLabel(AppLocalizations l10n, RecurrenceFrequency frequency) => + switch (frequency) { + RecurrenceFrequency.none => l10n.repeatNone, + RecurrenceFrequency.daily => l10n.repeatDaily, + RecurrenceFrequency.weekly => l10n.repeatWeekly, + RecurrenceFrequency.monthly => l10n.repeatMonthly, + RecurrenceFrequency.yearly => l10n.repeatYearly, + }; + +String _priorityLabel(AppLocalizations l10n, int priority) => priority == 0 + ? l10n.priorityNone + : priority <= 4 + ? l10n.priorityHighValue(priority) + : priority == 5 + ? l10n.priorityMediumValue(priority) + : l10n.priorityLowValue(priority); + +List _categories(String value) => [ + for (final category in value.split(',')) + if (category.trim().isNotEmpty) category.trim(), +]; diff --git a/lib/src/ui/windows/windows_tasks_page.dart b/lib/src/ui/windows/windows_tasks_page.dart new file mode 100644 index 00000000..c44dfe11 --- /dev/null +++ b/lib/src/ui/windows/windows_tasks_page.dart @@ -0,0 +1,211 @@ +import 'dart:async'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../app/busymax_shortcuts.dart'; +import '../../schedule/schedule_filters.dart'; +import '../../schedule/schedule_item.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; +import 'windows_task_details_dialog.dart'; +import 'windows_task_editor_dialog.dart'; + +class WindowsTasksPage extends ConsumerStatefulWidget { + const WindowsTasksPage({super.key}); + + @override + ConsumerState createState() => _WindowsTasksPageState(); +} + +class _WindowsTasksPageState extends ConsumerState { + final _searchController = TextEditingController(); + final _searchFocusNode = FocusNode(); + late Future> _tasks = _load(); + var _query = ''; + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + Future> _load() async { + final repository = ref.read(scheduleRepositoryProvider); + final filters = ScheduleFilters( + query: _query, + includeCalendarEvents: false, + includeTasks: true, + showCompletedTasks: true, + showNoDateTasks: true, + ); + return repository.listAllTasks(filters: filters); + } + + void _reload() => setState(() => _tasks = _load()); + + Future _createTask() async { + final changed = await showWindowsTaskEditorDialog(context, ref); + if (changed && mounted) _reload(); + } + + void _dismissSearch() { + if (!_searchFocusNode.hasFocus && _query.isEmpty) return; + _searchController.clear(); + _query = ''; + _searchFocusNode.unfocus(); + _reload(); + } + + void _createTaskFromShortcut() { + final focusContext = FocusManager.instance.primaryFocus?.context; + if (focusContext != null && + (focusContext.widget is EditableText || + focusContext.findAncestorWidgetOfExactType() != + null)) { + return; + } + unawaited(_createTask()); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + return CallbackShortcuts( + bindings: { + BusyMaxShortcutActivators.search: _searchFocusNode.requestFocus, + BusyMaxShortcutActivators.dismiss: _dismissSearch, + const SingleActivator(LogicalKeyboardKey.keyT): _createTaskFromShortcut, + }, + child: ScaffoldPage( + header: PageHeader( + title: Text(l10n.tasks), + commandBar: CommandBar( + primaryItems: [ + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.add)), + label: Text(l10n.newTask), + onPressed: () => unawaited(_createTask()), + ), + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.refresh)), + label: Text(l10n.refresh), + onPressed: _reload, + ), + ], + ), + ), + content: Column( + children: [ + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(24, 0, 24, 12), + child: TextBox( + controller: _searchController, + focusNode: _searchFocusNode, + placeholder: l10n.windowsSearch, + prefix: Padding( + padding: const EdgeInsetsDirectional.only(start: 10), + child: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.search)), + ), + onChanged: (value) { + _query = value; + _reload(); + }, + ), + ), + Expanded( + child: FutureBuilder>( + future: _tasks, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: ProgressRing()); + } + if (snapshot.hasError) { + return Center( + child: InfoBar( + title: Text(l10n.scheduleUnavailable), + severity: InfoBarSeverity.error, + action: Button( + onPressed: _reload, + child: Text(l10n.retry), + ), + ), + ); + } + final tasks = snapshot.data ?? const []; + if (tasks.isEmpty) { + return Center( + child: Text( + _query.isEmpty + ? l10n.signInToViewTasks + : l10n.scheduleNoSearchResults, + ), + ); + } + return ListView.builder( + padding: const EdgeInsetsDirectional.fromSTEB( + 24, + 0, + 24, + 24, + ), + itemCount: tasks.length, + itemBuilder: (context, index) { + final task = tasks[index]; + final due = task.start == null + ? '' + : DateFormat.yMMMd(locale).format(task.start!); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Card( + child: ListTile( + leading: Icon( + windowsBusyMaxGlyph( + task.completed + ? BusyMaxGlyph.check + : BusyMaxGlyph.task, + ), + ), + title: Text( + task.title, + style: task.completed + ? const TextStyle( + decoration: TextDecoration.lineThrough, + ) + : null, + ), + subtitle: Text( + [ + due, + ?task.sourceName, + ].where((value) => value.isNotEmpty).join(' · '), + ), + onPressed: () => unawaited( + showWindowsTaskDetailsDialog( + context, + ref, + task, + ).then((changed) { + if (changed) _reload(); + }), + ), + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/ui/windows/windows_time_zone_dialog.dart b/lib/src/ui/windows/windows_time_zone_dialog.dart new file mode 100644 index 00000000..c18eb0a9 --- /dev/null +++ b/lib/src/ui/windows/windows_time_zone_dialog.dart @@ -0,0 +1,86 @@ +import 'package:fluent_ui/fluent_ui.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../core/time/time_zone_catalog.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; + +Future showWindowsTimeZoneDialog( + BuildContext context, { + required String selectedTimeZone, +}) async { + final search = TextEditingController(); + var selected = selectedTimeZone; + var results = []; + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + return ContentDialog( + title: Text(l10n.selectTimeZone), + constraints: const BoxConstraints(maxWidth: 620, maxHeight: 640), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextBox( + controller: search, + autofocus: true, + placeholder: l10n.searchLocations, + prefix: Padding( + padding: const EdgeInsetsDirectional.only(start: 8), + child: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.search)), + ), + onChanged: (query) => setState( + () => results = BusyMaxTimeZoneCatalog.search( + query, + ).take(100).toList(), + ), + ), + const SizedBox(height: 12), + Text( + BusyMaxTimeZoneCatalog.location(selected).displayLabel, + style: FluentTheme.of(context).typography.bodyStrong, + ), + const SizedBox(height: 8), + Flexible( + child: results.isEmpty + ? Center(child: Text(l10n.noLocationsFound)) + : ListView.builder( + shrinkWrap: true, + itemCount: results.length, + itemBuilder: (context, index) { + final location = results[index]; + return ListTile.selectable( + selected: location.id == selected, + title: Text(location.displayLabel), + subtitle: Text( + '${location.region} · ${location.name}', + ), + onPressed: () => + setState(() => selected = location.id), + ); + }, + ), + ), + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, selected), + child: Text(l10n.save), + ), + ], + ); + }, + ), + ); + search.dispose(); + return result; +} diff --git a/lib/src/ui/windows/windows_workspace_shell.dart b/lib/src/ui/windows/windows_workspace_shell.dart new file mode 100644 index 00000000..e61bd24d --- /dev/null +++ b/lib/src/ui/windows/windows_workspace_shell.dart @@ -0,0 +1,74 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/busymax_shortcuts.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; +import 'windows_keyboard_shortcuts_dialog.dart'; + +enum WindowsWorkspaceDestination { schedule, tasks, settings } + +class WindowsWorkspaceShell extends StatelessWidget { + const WindowsWorkspaceShell({ + required this.destination, + required this.child, + super.key, + }); + + final WindowsWorkspaceDestination destination; + final Widget child; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final selected = destination.index; + return CallbackShortcuts( + bindings: { + BusyMaxShortcutActivators.settings: () => context.go('/settings'), + BusyMaxShortcutActivators.keyboardShortcuts: () => + showWindowsKeyboardShortcutsDialog(context), + }, + child: Focus( + autofocus: true, + child: NavigationView( + pane: NavigationPane( + selected: selected, + displayMode: PaneDisplayMode.compact, + size: const NavigationPaneSize(compactWidth: 48, openWidth: 240), + header: const Padding( + padding: EdgeInsetsDirectional.only(start: 12), + child: Text('BusyMax'), + ), + onChanged: (index) { + context.go(switch (index) { + 0 => '/schedule', + 1 => '/tasks', + _ => '/settings', + }); + }, + items: [ + PaneItem( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.calendar)), + title: Text(l10n.calendar), + body: selected == 0 ? child : const SizedBox.shrink(), + ), + PaneItem( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.task)), + title: Text(l10n.tasks), + body: selected == 1 ? child : const SizedBox.shrink(), + ), + ], + footerItems: [ + PaneItem( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.settings)), + title: Text(l10n.settings), + body: selected == 2 ? child : const SizedBox.shrink(), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/webcal/webcal_http_client.dart b/lib/src/webcal/webcal_http_client.dart new file mode 100644 index 00000000..bbce0eb0 --- /dev/null +++ b/lib/src/webcal/webcal_http_client.dart @@ -0,0 +1,265 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import '../ical/ical_ingestion.dart'; + +final class WebCalHttpException implements Exception { + const WebCalHttpException(this.code, {this.httpStatus}); + + final String code; + final int? httpStatus; + + @override + String toString() => + 'WebCalHttpException($code${httpStatus == null ? '' : ', status: $httpStatus'})'; +} + +final class WebCalHttpValidators { + const WebCalHttpValidators({this.etag, this.lastModified}); + + final String? etag; + final String? lastModified; + + bool get isEmpty => etag == null && lastModified == null; +} + +final class WebCalHttpResponse { + const WebCalHttpResponse({ + required this.statusCode, + required this.finalUri, + required this.body, + required this.etag, + required this.lastModified, + required this.contentType, + required this.conditionalRequestSent, + }); + + final int statusCode; + final Uri finalUri; + final Uint8List? body; + final String? etag; + final String? lastModified; + final String? contentType; + final bool conditionalRequestSent; +} + +abstract interface class WebCalHttpTransport { + Future get( + Uri uri, { + WebCalHttpValidators validators = const WebCalHttpValidators(), + Uri? validatorTarget, + }); +} + +final class WebCalHttpLimits { + const WebCalHttpLimits({ + this.connectTimeout = const Duration(seconds: 15), + this.streamInactivityTimeout = const Duration(seconds: 30), + this.totalDeadline = const Duration(minutes: 2), + this.maximumDecodedBodyBytes = icalIngestionDecodedBodyLimit, + this.maximumRedirects = 5, + }); + + final Duration connectTimeout; + final Duration streamInactivityTimeout; + final Duration totalDeadline; + final int maximumDecodedBodyBytes; + final int maximumRedirects; +} + +final class IoWebCalHttpTransport implements WebCalHttpTransport { + IoWebCalHttpTransport({ + HttpClient Function()? clientFactory, + this.limits = const WebCalHttpLimits(), + }) : _clientFactory = clientFactory ?? HttpClient.new; + + final HttpClient Function() _clientFactory; + final WebCalHttpLimits limits; + + @override + Future get( + Uri uri, { + WebCalHttpValidators validators = const WebCalHttpValidators(), + Uri? validatorTarget, + }) async { + final deadline = DateTime.now().add(limits.totalDeadline); + final visited = {}; + var current = uri; + var redirects = 0; + while (true) { + _requireHttpsTarget(current); + if (!visited.add(current.toString())) { + throw const WebCalHttpException('WebCalRedirectLoop'); + } + if (redirects > limits.maximumRedirects) { + throw const WebCalHttpException('WebCalRedirectLimitExceeded'); + } + final response = await _singleGet( + current, + validators: validatorTarget == current + ? validators + : const WebCalHttpValidators(), + deadline: deadline, + ); + if (!_redirectStatuses.contains(response.statusCode)) return response; + final location = response.redirectLocation; + if (location == null) { + throw WebCalHttpException( + 'WebCalRedirectMissingLocation', + httpStatus: response.statusCode, + ); + } + final next = current.resolveUri(location); + _requireHttpsTarget(next); + current = next; + redirects += 1; + } + } + + Future<_SingleResponse> _singleGet( + Uri target, { + required WebCalHttpValidators validators, + required DateTime deadline, + }) async { + final client = _clientFactory() + ..connectionTimeout = limits.connectTimeout + ..autoUncompress = true; + try { + final remaining = _remaining(deadline); + final request = await client + .getUrl(target) + .timeout( + remaining, + onTimeout: () => + throw const WebCalHttpException('WebCalTotalTimeout'), + ); + request + ..followRedirects = false + ..maxRedirects = 0; + request.headers + ..set(HttpHeaders.acceptHeader, 'text/calendar') + ..removeAll(HttpHeaders.authorizationHeader) + ..removeAll(HttpHeaders.cookieHeader) + ..removeAll(HttpHeaders.refererHeader) + ..removeAll('Origin'); + if (validators.etag != null) { + request.headers.set(HttpHeaders.ifNoneMatchHeader, validators.etag!); + } + if (validators.lastModified != null) { + request.headers.set( + HttpHeaders.ifModifiedSinceHeader, + validators.lastModified!, + ); + } + final response = await request.close().timeout( + _remaining(deadline), + onTimeout: () => throw const WebCalHttpException('WebCalTotalTimeout'), + ); + final redirectLocation = switch (response.headers.value( + HttpHeaders.locationHeader, + )) { + final value? => Uri.tryParse(value), + null => null, + }; + Uint8List? body; + if (response.statusCode != HttpStatus.notModified && + !_redirectStatuses.contains(response.statusCode)) { + body = await _readLimitedBody( + response, + deadline: deadline, + inactivityTimeout: limits.streamInactivityTimeout, + maximumBytes: limits.maximumDecodedBodyBytes, + ); + } + return _SingleResponse( + statusCode: response.statusCode, + finalUri: target, + body: body, + etag: response.headers.value(HttpHeaders.etagHeader), + lastModified: response.headers.value(HttpHeaders.lastModifiedHeader), + contentType: response.headers.value(HttpHeaders.contentTypeHeader), + conditionalRequestSent: !validators.isEmpty, + redirectLocation: redirectLocation, + ); + } on WebCalHttpException { + rethrow; + } on TimeoutException { + throw const WebCalHttpException('WebCalNetworkTimeout'); + } on SocketException { + throw const WebCalHttpException('WebCalNetworkFailure'); + } on HttpException { + throw const WebCalHttpException('WebCalNetworkFailure'); + } finally { + client.close(force: true); + } + } +} + +const _redirectStatuses = {301, 302, 303, 307, 308}; + +void _requireHttpsTarget(Uri uri) { + if (uri.scheme != 'https' || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.hasFragment) { + throw const WebCalHttpException('WebCalRedirectTargetNotAllowed'); + } +} + +Future _readLimitedBody( + HttpClientResponse response, { + required DateTime deadline, + required Duration inactivityTimeout, + required int maximumBytes, +}) async { + final builder = BytesBuilder(copy: false); + final iterator = StreamIterator>(response); + try { + while (true) { + final remaining = _remaining(deadline); + final limitedByDeadline = remaining <= inactivityTimeout; + final wait = limitedByDeadline ? remaining : inactivityTimeout; + final hasNext = await iterator.moveNext().timeout( + wait, + onTimeout: () => throw WebCalHttpException( + limitedByDeadline + ? 'WebCalTotalTimeout' + : 'WebCalStreamInactivityTimeout', + ), + ); + if (!hasNext) break; + final chunk = iterator.current; + if (builder.length + chunk.length > maximumBytes) { + throw const WebCalHttpException('WebCalBodyTooLarge'); + } + builder.add(chunk); + } + } finally { + await iterator.cancel(); + } + return builder.takeBytes(); +} + +Duration _remaining(DateTime deadline) { + final value = deadline.difference(DateTime.now()); + if (value <= Duration.zero) { + throw const WebCalHttpException('WebCalTotalTimeout'); + } + return value; +} + +final class _SingleResponse extends WebCalHttpResponse { + const _SingleResponse({ + required super.statusCode, + required super.finalUri, + required super.body, + required super.etag, + required super.lastModified, + required super.contentType, + required super.conditionalRequestSent, + required this.redirectLocation, + }); + + final Uri? redirectLocation; +} diff --git a/lib/src/webcal/webcal_subscription_service.dart b/lib/src/webcal/webcal_subscription_service.dart new file mode 100644 index 00000000..470e729c --- /dev/null +++ b/lib/src/webcal/webcal_subscription_service.dart @@ -0,0 +1,1137 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:crypto/crypto.dart'; +import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; + +import '../core/secrets/secret_store.dart'; +import '../dav/dav_errors.dart'; +import '../db/app_database.dart'; +import '../features/accounts/data/accounts_repository.dart'; +import '../features/notifications/notification_schedule_service.dart'; +import '../ical/ical_event_projection.dart'; +import '../ical/ical_ingestion.dart'; +import '../providers/busy_provider.dart'; +import 'webcal_http_client.dart'; +import 'webcal_uri.dart'; + +const webCalParserVersion = 1; +const webCalProjectionVersion = 2; + +enum WebCalRefreshMode { automatic, hourly, sixHours, daily } + +extension WebCalRefreshModeStorage on WebCalRefreshMode { + String get storageValue => switch (this) { + WebCalRefreshMode.automatic => 'automatic', + WebCalRefreshMode.hourly => 'hourly', + WebCalRefreshMode.sixHours => 'six_hours', + WebCalRefreshMode.daily => 'daily', + }; + + static WebCalRefreshMode parse(String value) => switch (value) { + 'hourly' => WebCalRefreshMode.hourly, + 'six_hours' => WebCalRefreshMode.sixHours, + 'daily' => WebCalRefreshMode.daily, + _ => WebCalRefreshMode.automatic, + }; +} + +final class WebCalSubscriptionEntity { + const WebCalSubscriptionEntity({ + required this.id, + required this.accountId, + required this.calendarSourceId, + required this.name, + required this.color, + required this.safeOrigin, + required this.refreshMode, + required this.nextRefreshAtUtc, + required this.lastSuccessfulSyncAtUtc, + required this.lastFailureCode, + required this.lastFailureHttpStatus, + required this.consecutiveFailureCount, + }); + + final String id; + final String accountId; + final String calendarSourceId; + final String name; + final String? color; + final String safeOrigin; + final WebCalRefreshMode refreshMode; + final DateTime nextRefreshAtUtc; + final DateTime? lastSuccessfulSyncAtUtc; + final String? lastFailureCode; + final int? lastFailureHttpStatus; + final int consecutiveFailureCount; +} + +final class WebCalSubscriptionException implements Exception { + const WebCalSubscriptionException(this.code, {this.httpStatus}); + + final String code; + final int? httpStatus; + + @override + String toString() => 'WebCalSubscriptionException($code)'; +} + +final class WebCalSubscriptionService { + WebCalSubscriptionService({ + required AppDatabase database, + required SecretStore secretStore, + required WebCalHttpTransport httpTransport, + NotificationScheduleService? notificationScheduleService, + Future Function()? onNotificationScheduleChanged, + DateTime Function()? nowUtc, + String Function()? idFactory, + IcalEventProjector? eventProjector, + }) : _database = database, + _secretStore = secretStore, + _httpTransport = httpTransport, + _notificationScheduleService = + notificationScheduleService ?? + NotificationScheduleService(database: database), + _onNotificationScheduleChanged = onNotificationScheduleChanged, + _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()), + _idFactory = idFactory ?? const Uuid().v4, + _eventProjector = eventProjector ?? IcalEventProjector(); + + final AppDatabase _database; + final SecretStore _secretStore; + final WebCalHttpTransport _httpTransport; + final NotificationScheduleService _notificationScheduleService; + final Future Function()? _onNotificationScheduleChanged; + final DateTime Function() _nowUtc; + final String Function() _idFactory; + final IcalEventProjector _eventProjector; + final Map> _operationTails = {}; + + Stream> watchSubscriptions() { + final query = _database.select(_database.webCalSubscriptions).join([ + innerJoin( + _database.calendarSources, + _database.calendarSources.id.equalsExp( + _database.webCalSubscriptions.calendarSourceId, + ), + ), + ])..orderBy([OrderingTerm.asc(_database.calendarSources.summary)]); + return query.watch().map( + (rows) => [ + for (final row in rows) + _entity( + row.readTable(_database.webCalSubscriptions), + row.readTable(_database.calendarSources), + ), + ], + ); + } + + Future> listSubscriptions() async { + final rows = await (_database.select(_database.webCalSubscriptions).join([ + innerJoin( + _database.calendarSources, + _database.calendarSources.id.equalsExp( + _database.webCalSubscriptions.calendarSourceId, + ), + ), + ])..orderBy([OrderingTerm.asc(_database.calendarSources.summary)])).get(); + return [ + for (final row in rows) + _entity( + row.readTable(_database.webCalSubscriptions), + row.readTable(_database.calendarSources), + ), + ]; + } + + Future addSubscription({ + required String subscriptionUrl, + String? localName, + String? color, + WebCalRefreshMode refreshMode = WebCalRefreshMode.automatic, + }) async { + final normalized = normalizeWebCalUri(subscriptionUrl); + final duplicate = + await (_database.select(_database.webCalSubscriptions)..where( + (row) => row.feedFingerprint.equals(normalized.fingerprint), + )) + .getSingleOrNull(); + if (duplicate != null) { + throw const WebCalSubscriptionException('WebCalDuplicateSubscription'); + } + final response = await _httpTransport.get(normalized.uri); + final candidate = _prepareAcceptedResponse( + response, + secretValues: { + normalized.credentialUri, + normalized.uri.toString(), + response.finalUri.toString(), + }, + ); + final subscriptionId = _idFactory(); + final accountId = 'webcal-account-$subscriptionId'; + final sourceId = 'webcal-calendar-$subscriptionId'; + final name = _subscriptionName(localName); + final now = _nowUtc(); + final targetFingerprint = webCalUriFingerprint(response.finalUri); + final secret = WebCalSecretRecord( + normalizedSubscriptionUri: normalized.credentialUri, + validatorTargetUri: response.finalUri, + ); + await _secretStore.saveCredential(accountId, secret); + try { + await _database.transaction(() async { + await _database + .into(_database.accounts) + .insert( + AccountsCompanion.insert( + id: accountId, + provider: BusyProvider.webCal.storageValue, + authority: normalized.safeOrigin.toString(), + providerAccountId: normalized.fingerprint, + credentialKind: CredentialKind.webCalSubscription.storageValue, + displayName: Value(name), + email: const Value(null), + tenantId: const Value(null), + authState: const Value(accountAuthStateSignedIn), + calendarsEnabled: const Value(true), + tasksEnabled: const Value(false), + grantedScopes: const Value(''), + createdAtUtc: now.toIso8601String(), + updatedAtUtc: now.toIso8601String(), + lastSuccessfulSyncAtUtc: Value(now.toIso8601String()), + ), + ); + await _database + .into(_database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: sourceId, + accountId: accountId, + provider: BusyProvider.webCal.storageValue, + providerCalendarId: subscriptionId, + summary: name, + primaryCalendar: const Value(false), + selected: const Value(true), + hidden: const Value(false), + readOnly: const Value(true), + backgroundColor: Value(_normalizedColor(color)), + accessRole: const Value('reader'), + rawJson: Value(jsonEncode(const {'transport': 'webcal'})), + createdAtLocal: now.millisecondsSinceEpoch, + updatedAtLocal: now.millisecondsSinceEpoch, + ), + ); + await _database + .into(_database.webCalSubscriptions) + .insert( + WebCalSubscriptionsCompanion.insert( + id: subscriptionId, + accountId: accountId, + calendarSourceId: sourceId, + feedFingerprint: normalized.fingerprint, + safeOrigin: normalized.safeOrigin.toString(), + validatorTargetFingerprint: Value(targetFingerprint), + etag: Value(response.etag), + lastModified: Value(response.lastModified), + contentType: Value(response.contentType), + snapshotIcsBody: candidate.snapshot, + rawBodyHash: candidate.rawBodyHash, + semanticHash: candidate.semanticHash, + refreshMode: refreshMode.storageValue, + serverRefreshIntervalSeconds: Value( + candidate.ingestion.serverRefreshIntervalSeconds, + ), + nextRefreshAtUtc: _nextRefresh( + now, + mode: refreshMode, + serverSeconds: + candidate.ingestion.serverRefreshIntervalSeconds, + subscriptionId: subscriptionId, + ).toIso8601String(), + lastCheckedAtUtc: Value(now.toIso8601String()), + lastSuccessfulSyncAtUtc: Value(now.toIso8601String()), + lastChangedAtUtc: Value(now.toIso8601String()), + parserVersion: const Value(webCalParserVersion), + projectionVersion: const Value(webCalProjectionVersion), + projectionRangeStartUtc: Value( + candidate.projectionRangeStartUtc.toIso8601String(), + ), + projectionRangeEndUtc: Value( + candidate.projectionRangeEndUtc.toIso8601String(), + ), + createdAtUtc: now.toIso8601String(), + updatedAtUtc: now.toIso8601String(), + ), + ); + await _replaceEvents( + subscriptionId: subscriptionId, + accountId: accountId, + sourceId: sourceId, + color: _normalizedColor(color), + projections: candidate.projections, + now: now, + ); + }); + } on Object { + await _secretStore.deleteCredential(accountId); + rethrow; + } + await _rebuildNotifications(accountId); + return subscriptionId; + } + + Future refreshSubscription( + String subscriptionId, { + bool force = false, + }) => _serializeSubscriptionOperation( + subscriptionId, + () => _refreshSubscription(subscriptionId, force: force), + ); + + Future _serializeSubscriptionOperation( + String subscriptionId, + Future Function() operation, + ) { + final previous = _operationTails[subscriptionId] ?? Future.value(); + final next = previous.catchError((Object _) {}).then((_) => operation()); + _operationTails[subscriptionId] = next; + return next.whenComplete(() { + if (identical(_operationTails[subscriptionId], next)) { + _operationTails.remove(subscriptionId); + } + }); + } + + Future refreshAccount(String accountId, {required bool force}) async { + final subscription = await (_database.select( + _database.webCalSubscriptions, + )..where((row) => row.accountId.equals(accountId))).getSingleOrNull(); + if (subscription == null) return; + await refreshSubscription(subscription.id, force: force); + } + + Future _refreshSubscription( + String subscriptionId, { + required bool force, + }) async { + final subscription = await (_database.select( + _database.webCalSubscriptions, + )..where((row) => row.id.equals(subscriptionId))).getSingleOrNull(); + if (subscription == null) return; + final now = _nowUtc(); + final due = DateTime.tryParse(subscription.nextRefreshAtUtc)?.toUtc(); + if (!force && due != null && due.isAfter(now)) return; + final secret = await _secretStore.readCredential(subscription.accountId); + if (secret is! WebCalSecretRecord) { + await _recordFailure( + subscription, + const WebCalSubscriptionException('WebCalSecretUnavailable'), + ); + return; + } + final target = secret.validatorTargetUri; + final subscriptionUri = Uri.parse(secret.normalizedSubscriptionUri); + final targetFingerprint = target == null + ? null + : webCalUriFingerprint(target); + final validatorsBound = + target != null && + targetFingerprint == subscription.validatorTargetFingerprint; + try { + final response = await _httpTransport.get( + subscriptionUri, + validators: validatorsBound + ? WebCalHttpValidators( + etag: subscription.etag, + lastModified: subscription.lastModified, + ) + : const WebCalHttpValidators(), + validatorTarget: validatorsBound ? target : null, + ); + if (response.statusCode == 304) { + if (!response.conditionalRequestSent || + !validatorsBound || + response.finalUri != target || + subscription.snapshotIcsBody.isEmpty) { + throw const WebCalSubscriptionException('WebCalInvalidNotModified'); + } + final cachedProjection = _prepareCachedProjectionForRefresh( + subscription, + now, + ); + final source = cachedProjection == null + ? null + : await (_database.select(_database.calendarSources)..where( + (row) => row.id.equals(subscription.calendarSourceId), + )) + .getSingle(); + await _database.transaction(() async { + if (cachedProjection != null) { + await _replaceEvents( + subscriptionId: subscription.id, + accountId: subscription.accountId, + sourceId: subscription.calendarSourceId, + color: source!.backgroundColor, + projections: cachedProjection.projections, + now: now, + ); + } + await (_database.update( + _database.webCalSubscriptions, + )..where((row) => row.id.equals(subscription.id))).write( + WebCalSubscriptionsCompanion( + etag: Value(response.etag ?? subscription.etag), + lastModified: Value( + response.lastModified ?? subscription.lastModified, + ), + lastCheckedAtUtc: Value(now.toIso8601String()), + lastSuccessfulSyncAtUtc: Value(now.toIso8601String()), + consecutiveFailureCount: const Value(0), + lastFailureCode: const Value(null), + lastFailureHttpStatus: const Value(null), + parserVersion: cachedProjection == null + ? const Value.absent() + : const Value(webCalParserVersion), + projectionVersion: cachedProjection == null + ? const Value.absent() + : const Value(webCalProjectionVersion), + projectionRangeStartUtc: cachedProjection == null + ? const Value.absent() + : Value(cachedProjection.rangeStartUtc.toIso8601String()), + projectionRangeEndUtc: cachedProjection == null + ? const Value.absent() + : Value(cachedProjection.rangeEndUtc.toIso8601String()), + nextRefreshAtUtc: Value( + _nextRefresh( + now, + mode: WebCalRefreshModeStorage.parse( + subscription.refreshMode, + ), + serverSeconds: subscription.serverRefreshIntervalSeconds, + subscriptionId: subscription.id, + ).toIso8601String(), + ), + updatedAtUtc: Value(now.toIso8601String()), + ), + ); + await (_database.update( + _database.accounts, + )..where((row) => row.id.equals(subscription.accountId))).write( + AccountsCompanion( + lastSuccessfulSyncAtUtc: Value(now.toIso8601String()), + updatedAtUtc: Value(now.toIso8601String()), + ), + ); + }); + if (cachedProjection != null) { + await _rebuildNotifications(subscription.accountId); + } + return; + } + final candidate = _prepareAcceptedResponse( + response, + secretValues: { + secret.normalizedSubscriptionUri, + subscriptionUri.toString(), + if (target != null) target.toString(), + response.finalUri.toString(), + }, + ); + final source = + await (_database.select(_database.calendarSources) + ..where((row) => row.id.equals(subscription.calendarSourceId))) + .getSingle(); + final nextSecret = secret.copyWithValidatorTarget(response.finalUri); + await _secretStore.saveCredential(subscription.accountId, nextSecret); + try { + await _database.transaction(() async { + await _replaceEvents( + subscriptionId: subscription.id, + accountId: subscription.accountId, + sourceId: subscription.calendarSourceId, + color: source.backgroundColor, + projections: candidate.projections, + now: now, + ); + await (_database.update( + _database.webCalSubscriptions, + )..where((row) => row.id.equals(subscription.id))).write( + WebCalSubscriptionsCompanion( + validatorTargetFingerprint: Value( + webCalUriFingerprint(response.finalUri), + ), + etag: Value(response.etag), + lastModified: Value(response.lastModified), + contentType: Value(response.contentType), + snapshotIcsBody: Value(candidate.snapshot), + rawBodyHash: Value(candidate.rawBodyHash), + semanticHash: Value(candidate.semanticHash), + serverRefreshIntervalSeconds: Value( + candidate.ingestion.serverRefreshIntervalSeconds, + ), + lastCheckedAtUtc: Value(now.toIso8601String()), + lastSuccessfulSyncAtUtc: Value(now.toIso8601String()), + lastChangedAtUtc: Value( + candidate.semanticHash == subscription.semanticHash + ? subscription.lastChangedAtUtc + : now.toIso8601String(), + ), + consecutiveFailureCount: const Value(0), + lastFailureCode: const Value(null), + lastFailureHttpStatus: const Value(null), + generation: Value(subscription.generation + 1), + parserVersion: const Value(webCalParserVersion), + projectionVersion: const Value(webCalProjectionVersion), + projectionRangeStartUtc: Value( + candidate.projectionRangeStartUtc.toIso8601String(), + ), + projectionRangeEndUtc: Value( + candidate.projectionRangeEndUtc.toIso8601String(), + ), + nextRefreshAtUtc: Value( + _nextRefresh( + now, + mode: WebCalRefreshModeStorage.parse( + subscription.refreshMode, + ), + serverSeconds: + candidate.ingestion.serverRefreshIntervalSeconds, + subscriptionId: subscription.id, + ).toIso8601String(), + ), + updatedAtUtc: Value(now.toIso8601String()), + ), + ); + await (_database.update( + _database.accounts, + )..where((row) => row.id.equals(subscription.accountId))).write( + AccountsCompanion( + authState: const Value(accountAuthStateSignedIn), + lastSuccessfulSyncAtUtc: Value(now.toIso8601String()), + updatedAtUtc: Value(now.toIso8601String()), + ), + ); + }); + } on Object { + await _secretStore.saveCredential(subscription.accountId, secret); + rethrow; + } + await _rebuildNotifications(subscription.accountId); + } on Object catch (error) { + await _recordFailure(subscription, error); + rethrow; + } + } + + Future renameSubscription(String subscriptionId, String name) { + final trimmed = _subscriptionName(name); + return _serializeSubscriptionOperation( + subscriptionId, + () => _renameSubscription(subscriptionId, trimmed), + ); + } + + Future _renameSubscription( + String subscriptionId, + String trimmed, + ) async { + final subscription = await _subscription(subscriptionId); + final now = _nowUtc(); + await _database.transaction(() async { + await (_database.update( + _database.accounts, + )..where((row) => row.id.equals(subscription.accountId))).write( + AccountsCompanion( + displayName: Value(trimmed), + updatedAtUtc: Value(now.toIso8601String()), + ), + ); + await (_database.update( + _database.calendarSources, + )..where((row) => row.id.equals(subscription.calendarSourceId))).write( + CalendarSourcesCompanion( + summary: Value(trimmed), + updatedAtLocal: Value(now.millisecondsSinceEpoch), + ), + ); + }); + } + + Future changeSubscriptionColor(String subscriptionId, String? color) { + final normalized = _normalizedColor(color); + return _serializeSubscriptionOperation( + subscriptionId, + () => _changeSubscriptionColor(subscriptionId, normalized), + ); + } + + Future _changeSubscriptionColor( + String subscriptionId, + String? normalized, + ) async { + final subscription = await _subscription(subscriptionId); + final now = _nowUtc().millisecondsSinceEpoch; + await _database.transaction(() async { + await (_database.update( + _database.calendarSources, + )..where((row) => row.id.equals(subscription.calendarSourceId))).write( + CalendarSourcesCompanion( + backgroundColor: Value(normalized), + updatedAtLocal: Value(now), + ), + ); + await (_database.update(_database.calendarEvents)..where( + (row) => row.calendarSourceId.equals(subscription.calendarSourceId), + )) + .write( + CalendarEventsCompanion( + colorHex: Value(normalized), + updatedAtLocal: Value(now), + ), + ); + }); + } + + Future changeRefreshMode( + String subscriptionId, + WebCalRefreshMode mode, + ) => _serializeSubscriptionOperation( + subscriptionId, + () => _changeRefreshMode(subscriptionId, mode), + ); + + Future _changeRefreshMode( + String subscriptionId, + WebCalRefreshMode mode, + ) async { + final subscription = await _subscription(subscriptionId); + final now = _nowUtc(); + await (_database.update( + _database.webCalSubscriptions, + )..where((row) => row.id.equals(subscription.id))).write( + WebCalSubscriptionsCompanion( + refreshMode: Value(mode.storageValue), + nextRefreshAtUtc: Value( + _nextRefresh( + now, + mode: mode, + serverSeconds: subscription.serverRefreshIntervalSeconds, + subscriptionId: subscription.id, + ).toIso8601String(), + ), + updatedAtUtc: Value(now.toIso8601String()), + ), + ); + } + + Future unsubscribe(String subscriptionId) => + _serializeSubscriptionOperation( + subscriptionId, + () => _unsubscribe(subscriptionId), + ); + + Future _unsubscribe(String subscriptionId) async { + final subscription = await _subscription(subscriptionId); + final secret = await _secretStore.readCredential(subscription.accountId); + await _secretStore.deleteCredential(subscription.accountId); + try { + await _database.transaction(() async { + await (_database.delete( + _database.accounts, + )..where((row) => row.id.equals(subscription.accountId))).go(); + }); + } on Object { + if (secret != null) { + await _secretStore.saveCredential(subscription.accountId, secret); + } + rethrow; + } + await _onNotificationScheduleChanged?.call(); + } + + Future _subscription(String id) async { + final value = await (_database.select( + _database.webCalSubscriptions, + )..where((row) => row.id.equals(id))).getSingleOrNull(); + if (value == null) { + throw const WebCalSubscriptionException('WebCalSubscriptionNotFound'); + } + return value; + } + + Future ensureProjectionCoverage({ + required DateTime rangeStartUtc, + required DateTime rangeEndUtc, + }) async { + final requestedStart = rangeStartUtc.toUtc(); + final requestedEnd = rangeEndUtc.toUtc(); + if (!requestedEnd.isAfter(requestedStart)) return; + final subscriptions = await _database + .select(_database.webCalSubscriptions) + .get(); + for (final subscription in subscriptions) { + if (!_projectionCoverageRequired( + subscription, + requestedStart: requestedStart, + requestedEnd: requestedEnd, + )) { + continue; + } + await _serializeSubscriptionOperation( + subscription.id, + () => _ensureProjectionCoverage( + subscription.id, + requestedStart: requestedStart, + requestedEnd: requestedEnd, + ), + ); + } + } + + Future _ensureProjectionCoverage( + String subscriptionId, { + required DateTime requestedStart, + required DateTime requestedEnd, + }) async { + final subscription = await (_database.select( + _database.webCalSubscriptions, + )..where((row) => row.id.equals(subscriptionId))).getSingleOrNull(); + if (subscription == null || + !_projectionCoverageRequired( + subscription, + requestedStart: requestedStart, + requestedEnd: requestedEnd, + )) { + return; + } + final range = _projectionRange( + _nowUtc(), + requestedStart: requestedStart, + requestedEnd: requestedEnd, + ); + final projection = _prepareCachedProjection( + subscription.snapshotIcsBody, + range: range, + ); + final source = + await (_database.select(_database.calendarSources) + ..where((row) => row.id.equals(subscription.calendarSourceId))) + .getSingleOrNull(); + if (source == null) return; + final now = _nowUtc(); + await _database.transaction(() async { + await _replaceEvents( + subscriptionId: subscription.id, + accountId: subscription.accountId, + sourceId: subscription.calendarSourceId, + color: source.backgroundColor, + projections: projection.projections, + now: now, + ); + await (_database.update( + _database.webCalSubscriptions, + )..where((row) => row.id.equals(subscription.id))).write( + WebCalSubscriptionsCompanion( + parserVersion: const Value(webCalParserVersion), + projectionVersion: const Value(webCalProjectionVersion), + projectionRangeStartUtc: Value( + projection.rangeStartUtc.toIso8601String(), + ), + projectionRangeEndUtc: Value( + projection.rangeEndUtc.toIso8601String(), + ), + updatedAtUtc: Value(now.toIso8601String()), + ), + ); + }); + await _rebuildNotifications(subscription.accountId); + } + + _PreparedProjection? _prepareCachedProjectionForRefresh( + WebCalSubscription subscription, + DateTime now, + ) { + final minimumFuture = DateTime.utc(now.year + 1, now.month, now.day); + if (!_projectionCoverageRequired( + subscription, + requestedStart: now, + requestedEnd: minimumFuture, + includePadding: false, + )) { + return null; + } + return _prepareCachedProjection( + subscription.snapshotIcsBody, + range: _projectionRange(now), + ); + } + + bool _projectionCoverageRequired( + WebCalSubscription subscription, { + required DateTime requestedStart, + required DateTime requestedEnd, + bool includePadding = true, + }) { + if (subscription.parserVersion != webCalParserVersion || + subscription.projectionVersion != webCalProjectionVersion) { + return true; + } + final storedStart = DateTime.tryParse( + subscription.projectionRangeStartUtc ?? '', + )?.toUtc(); + final storedEnd = DateTime.tryParse( + subscription.projectionRangeEndUtc ?? '', + )?.toUtc(); + if (storedStart == null || storedEnd == null) return true; + final requiredStart = includePadding + ? requestedStart.subtract(const Duration(days: 30)) + : requestedStart; + final requiredEnd = includePadding + ? requestedEnd.add(const Duration(days: 90)) + : requestedEnd; + return storedStart.isAfter(requiredStart) || + storedEnd.isBefore(requiredEnd); + } + + _PreparedProjection _prepareCachedProjection( + String snapshot, { + required ({DateTime start, DateTime end}) range, + }) { + final ingestion = IcalIngestion.parseString( + snapshot, + policy: IcalIngestionPolicy.webCal, + ); + final projections = []; + try { + for (final set in ingestion.recurrenceSets) { + projections.addAll( + _eventProjector.project( + set, + rangeStartUtc: range.start, + rangeEndUtc: range.end, + transport: 'webcal', + ), + ); + } + } on DavException catch (error) { + throw WebCalSubscriptionException(error.code); + } + return _PreparedProjection( + ingestion: ingestion, + projections: projections, + rangeStartUtc: range.start, + rangeEndUtc: range.end, + ); + } + + _PreparedCandidate _prepareAcceptedResponse( + WebCalHttpResponse response, { + required Iterable secretValues, + }) { + if (response.finalUri.scheme != 'https' || + response.finalUri.host.isEmpty || + response.finalUri.userInfo.isNotEmpty || + response.finalUri.hasFragment) { + throw const WebCalSubscriptionException('WebCalUnsafeResponseTarget'); + } + if (response.statusCode != 200 || response.body == null) { + throw WebCalSubscriptionException( + 'WebCalHttpStatus', + httpStatus: response.statusCode, + ); + } + final contentType = response.contentType + ?.split(';') + .first + .trim() + .toLowerCase(); + if (contentType == 'text/html' || + contentType == 'application/json' || + (contentType != null && + contentType.isNotEmpty && + contentType != 'text/calendar' && + contentType != 'text/plain' && + contentType != 'application/octet-stream')) { + throw const WebCalSubscriptionException('WebCalContentTypeRejected'); + } + final body = response.body!; + final leading = utf8 + .decode( + body.take(math.min(body.length, 64)).toList(), + allowMalformed: true, + ) + .trimLeft() + .toLowerCase(); + if (leading.startsWith(' _replaceEvents({ + required String subscriptionId, + required String accountId, + required String sourceId, + required String? color, + required List projections, + required DateTime now, + }) async { + await (_database.delete( + _database.calendarEvents, + )..where((row) => row.calendarSourceId.equals(sourceId))).go(); + final timestamp = now.millisecondsSinceEpoch; + for (final projected in projections) { + final itemId = _stableId( + 'webcal-item', + '$subscriptionId\u0000${projected.uid}', + ); + final eventId = _stableId( + 'webcal-event', + '$itemId\u0000${_webCalOccurrenceIdentity(projected)}', + ); + await _database + .into(_database.calendarEvents) + .insert( + CalendarEventsCompanion.insert( + id: eventId, + accountId: accountId, + calendarSourceId: sourceId, + provider: BusyProvider.webCal.storageValue, + providerCalendarId: subscriptionId, + providerEventId: itemId, + icalUid: Value(projected.uid), + recurrenceIdKey: Value(projected.recurrenceIdKey), + occurrenceKey: Value(projected.occurrenceKey), + projectionVersion: const Value(webCalProjectionVersion), + providerRecurringEventId: Value( + projected.recurring ? itemId : null, + ), + providerOriginalStartKey: Value(projected.occurrenceKey), + status: Value(projected.status), + title: projected.title, + description: Value(projected.description), + location: Value(projected.location), + allDay: Value(projected.allDay), + startDate: Value(projected.startDate), + startDateTime: Value(projected.startDateTime), + startTimeZone: Value(projected.startTimeZone), + endDate: Value(projected.endDate), + endDateTime: Value(projected.endDateTime), + endTimeZone: Value(projected.endTimeZone), + recurrenceJson: Value(projected.recurrenceJson), + remindersJson: Value(projected.remindersJson), + attendeesJson: Value(projected.attendeesJson), + categoriesJson: Value(projected.categoriesJson), + organizerJson: Value(projected.organizerJson), + colorHex: Value(color), + visibility: Value(projected.visibility), + transparencyOrShowAs: Value(projected.transparency), + webLink: Value(projected.webLink), + attachmentsJson: Value(projected.attachmentsJson), + isCancelled: Value(projected.cancelled), + isDeleted: const Value(false), + rawJson: Value(projected.rawJson), + createdAtLocal: timestamp, + updatedAtLocal: timestamp, + syncStatus: const Value('synced'), + baselineRawJson: Value(projected.rawJson), + ), + ); + } + } + + Future _recordFailure( + WebCalSubscription subscription, + Object error, + ) async { + final now = _nowUtc(); + final failures = subscription.consecutiveFailureCount + 1; + final code = switch (error) { + WebCalHttpException(:final code) => code, + WebCalSubscriptionException(:final code) => code, + IcalIngestionException(:final code) => code, + _ => 'WebCalRefreshFailed', + }; + final status = switch (error) { + WebCalHttpException(:final httpStatus) => httpStatus, + WebCalSubscriptionException(:final httpStatus) => httpStatus, + _ => null, + }; + await (_database.update( + _database.webCalSubscriptions, + )..where((row) => row.id.equals(subscription.id))).write( + WebCalSubscriptionsCompanion( + lastCheckedAtUtc: Value(now.toIso8601String()), + consecutiveFailureCount: Value(failures), + lastFailureCode: Value(code), + lastFailureHttpStatus: Value(status), + nextRefreshAtUtc: Value( + now.add(_failureBackoff(failures)).toIso8601String(), + ), + updatedAtUtc: Value(now.toIso8601String()), + ), + ); + } + + Future _rebuildNotifications(String accountId) async { + await _notificationScheduleService.rebuildUpcomingEventNotifications( + accountId, + ); + await _onNotificationScheduleChanged?.call(); + } +} + +WebCalSubscriptionEntity _entity( + WebCalSubscription subscription, + CalendarSource source, +) => WebCalSubscriptionEntity( + id: subscription.id, + accountId: subscription.accountId, + calendarSourceId: subscription.calendarSourceId, + name: source.summary, + color: source.backgroundColor, + safeOrigin: subscription.safeOrigin, + refreshMode: WebCalRefreshModeStorage.parse(subscription.refreshMode), + nextRefreshAtUtc: DateTime.parse(subscription.nextRefreshAtUtc).toUtc(), + lastSuccessfulSyncAtUtc: DateTime.tryParse( + subscription.lastSuccessfulSyncAtUtc ?? '', + )?.toUtc(), + lastFailureCode: subscription.lastFailureCode, + lastFailureHttpStatus: subscription.lastFailureHttpStatus, + consecutiveFailureCount: subscription.consecutiveFailureCount, +); + +final class _PreparedCandidate { + const _PreparedCandidate({ + required this.ingestion, + required this.projections, + required this.snapshot, + required this.rawBodyHash, + required this.semanticHash, + required this.projectionRangeStartUtc, + required this.projectionRangeEndUtc, + }); + + final IcalIngestionResult ingestion; + final List projections; + final String snapshot; + final String rawBodyHash; + final String semanticHash; + final DateTime projectionRangeStartUtc; + final DateTime projectionRangeEndUtc; +} + +final class _PreparedProjection { + const _PreparedProjection({ + required this.ingestion, + required this.projections, + required this.rangeStartUtc, + required this.rangeEndUtc, + }); + + final IcalIngestionResult ingestion; + final List projections; + final DateTime rangeStartUtc; + final DateTime rangeEndUtc; +} + +({DateTime start, DateTime end}) _projectionRange( + DateTime now, { + DateTime? requestedStart, + DateTime? requestedEnd, +}) { + final utc = now.toUtc(); + final baseStart = DateTime.utc(utc.year - 1, utc.month, utc.day); + final baseEnd = DateTime.utc(utc.year + 3, utc.month, utc.day); + final paddedStart = requestedStart?.toUtc().subtract( + const Duration(days: 30), + ); + final paddedEnd = requestedEnd?.toUtc().add(const Duration(days: 90)); + return ( + start: paddedStart != null && paddedStart.isBefore(baseStart) + ? paddedStart + : baseStart, + end: paddedEnd != null && paddedEnd.isAfter(baseEnd) ? paddedEnd : baseEnd, + ); +} + +String _subscriptionName(String? value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) return 'Calendar subscription'; + if (trimmed.length > 200) { + throw const WebCalSubscriptionException('WebCalNameTooLong'); + } + return trimmed; +} + +String? _normalizedColor(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) return null; + if (!RegExp(r'^#[0-9A-Fa-f]{6}$').hasMatch(trimmed)) { + throw const WebCalSubscriptionException('WebCalColorInvalid'); + } + return trimmed.toUpperCase(); +} + +String _stableId(String prefix, String source) => + '$prefix-${sha256.convert(utf8.encode(source))}'; + +String _webCalOccurrenceIdentity(ProjectedIcalEvent event) => + event.recurring ? event.occurrenceKey : 'master'; + +DateTime _nextRefresh( + DateTime now, { + required WebCalRefreshMode mode, + required int? serverSeconds, + required String subscriptionId, +}) { + final selected = switch (mode) { + WebCalRefreshMode.automatic || + WebCalRefreshMode.sixHours => const Duration(hours: 6), + WebCalRefreshMode.hourly => const Duration(hours: 1), + WebCalRefreshMode.daily => const Duration(days: 1), + }; + final server = serverSeconds == null + ? Duration.zero + : Duration(seconds: serverSeconds); + final effective = [ + const Duration(hours: 1), + selected, + server, + ].reduce((left, right) => left > right ? left : right); + final seed = sha256.convert(utf8.encode(subscriptionId)).bytes.first; + final jitterSeconds = (effective.inSeconds * (seed / 255) * 0.1).round(); + return now.add(effective).add(Duration(seconds: jitterSeconds)); +} + +Duration _failureBackoff(int failures) { + final hours = math.min(24, 1 << math.min(4, math.max(0, failures - 1))); + return Duration(hours: hours); +} diff --git a/lib/src/webcal/webcal_uri.dart b/lib/src/webcal/webcal_uri.dart new file mode 100644 index 00000000..141fa4ce --- /dev/null +++ b/lib/src/webcal/webcal_uri.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +final class WebCalUriException implements FormatException { + const WebCalUriException(this.code); + + final String code; + + @override + String get message => 'The calendar subscription URL is not valid.'; + + @override + int? get offset => null; + + @override + String? get source => null; +} + +final class NormalizedWebCalUri { + const NormalizedWebCalUri._({ + required this.uri, + required this.credentialUri, + required this.safeOrigin, + required this.fingerprint, + }); + + final Uri uri; + final String credentialUri; + final Uri safeOrigin; + final String fingerprint; +} + +NormalizedWebCalUri normalizeWebCalUri(String input) { + final trimmed = input.trim(); + final parsed = Uri.tryParse(trimmed); + if (parsed == null) throw const WebCalUriException('WebCalUriMalformed'); + final scheme = parsed.scheme.toLowerCase(); + if (scheme != 'https' && scheme != 'webcal') { + throw const WebCalUriException('WebCalUriSchemeNotAllowed'); + } + if (parsed.host.isEmpty || parsed.userInfo.isNotEmpty || parsed.hasFragment) { + throw const WebCalUriException('WebCalUriAuthorityNotAllowed'); + } + final authorityStart = trimmed.indexOf('://') + 3; + final tailStart = _authorityEnd(trimmed, authorityStart); + final authority = trimmed.substring(authorityStart, tailStart); + final explicitPortText = _explicitPortText(authority); + final explicitPort = explicitPortText == null + ? null + : int.tryParse(explicitPortText); + if (explicitPortText != null && + (explicitPort == null || explicitPort < 1 || explicitPort > 65535)) { + throw const WebCalUriException('WebCalUriAuthorityNotAllowed'); + } + final normalizedAuthority = parsed.host.contains(':') + ? '[${parsed.host.toLowerCase()}]' + : parsed.host.toLowerCase(); + final normalizedText = + 'https://$normalizedAuthority' + '${explicitPort == null ? '' : ':$explicitPort'}' + '${trimmed.substring(tailStart)}'; + final normalized = Uri.parse(normalizedText); + final safeOrigin = Uri( + scheme: 'https', + host: normalized.host.toLowerCase(), + port: normalized.hasPort && normalized.port != 443 ? normalized.port : null, + ); + return NormalizedWebCalUri._( + uri: normalized, + credentialUri: normalizedText, + safeOrigin: safeOrigin, + fingerprint: webCalTextFingerprint(normalizedText), + ); +} + +String webCalUriFingerprint(Uri uri) => + sha256.convert(utf8.encode(uri.toString())).toString(); + +String webCalTextFingerprint(String uri) => + sha256.convert(utf8.encode(uri)).toString(); + +int _authorityEnd(String value, int start) { + for (var index = start; index < value.length; index += 1) { + final code = value.codeUnitAt(index); + if (code == 0x2f || code == 0x3f || code == 0x23) return index; + } + return value.length; +} + +String? _explicitPortText(String authority) { + if (authority.startsWith('[')) { + final end = authority.indexOf(']'); + if (end < 0 || end == authority.length - 1) return null; + if (authority.codeUnitAt(end + 1) != 0x3a) return null; + return authority.substring(end + 2); + } + final separator = authority.lastIndexOf(':'); + return separator < 0 ? null : authority.substring(separator + 1); +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index a246036a..7228addc 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,10 +9,8 @@ #include #include #include -#include #include #include -#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -25,18 +23,12 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); - g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); - screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); g_autoptr(FlPluginRegistrar) system_theme_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SystemThemePlugin"); system_theme_plugin_register_with_registrar(system_theme_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); - g_autoptr(FlPluginRegistrar) window_manager_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); - window_manager_plugin_register_with_registrar(window_manager_registrar); g_autoptr(FlPluginRegistrar) yaru_window_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "YaruWindowLinuxPlugin"); yaru_window_linux_plugin_register_with_registrar(yaru_window_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index a8c1e404..e8e9770f 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,10 +6,8 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux flutter_secure_storage_linux gtk - screen_retriever_linux system_theme url_launcher_linux - window_manager yaru_window_linux ) diff --git a/linux/io.busystack.busymax.desktop b/linux/io.busystack.busymax.desktop index 7499fc66..86e55ed8 100644 --- a/linux/io.busystack.busymax.desktop +++ b/linux/io.busystack.busymax.desktop @@ -24,8 +24,8 @@ Name[ja]=BusyMax Comment[ja]=カレンダー・タスク管理アプリ Name[ko]=BusyMax Comment[ko]=캘린더와 할 일 관리 -Name[pt]=BusyMax -Comment[pt]=Gestor de calendário e tarefas +Name[pt_PT]=BusyMax +Comment[pt_PT]=Gestor de calendário e tarefas Name[ru]=BusyMax Comment[ru]=Календарь и планировщик задач Name[vi]=BusyMax @@ -36,9 +36,10 @@ Name[zh_Hans]=BusyMax Comment[zh_Hans]=日历与任务管理工具 Name[zh_Hant]=BusyMax Comment[zh_Hant]=行事曆與待辦事項管理工具 -Exec=busymax +Exec=busymax %U Icon=io.busystack.busymax Terminal=false +MimeType=text/calendar;x-scheme-handler/webcal; Categories=Office;Calendar;ProjectManagement; StartupNotify=true StartupWMClass=io.busystack.busymax diff --git a/linux/io.busystack.busymax.metainfo.xml b/linux/io.busystack.busymax.metainfo.xml index aac5688f..24f8244f 100644 --- a/linux/io.busystack.busymax.metainfo.xml +++ b/linux/io.busystack.busymax.metainfo.xml @@ -15,7 +15,7 @@ BusyMax BusyMax BusyMax - BusyMax + BusyMax BusyMax BusyMax BusyMax @@ -33,7 +33,7 @@ Gestore di calendari e attività カレンダー・タスク管理アプリ 캘린더와 할 일 관리 - Gestor de calendário e tarefas + Gestor de calendário e tarefas Календарь и планировщик задач Trình quản lý lịch và công việc 日历与任务管理工具 @@ -52,7 +52,7 @@

BusyMax è un gestore di calendari e attività per desktop Linux che riunisce eventi, attività, promemoria e programmi giornalieri in un unico spazio di lavoro.

BusyMax は、予定、タスク、リマインダー、毎日のスケジュールを一つのワークスペースで管理できる Linux デスクトップ向けのカレンダー・タスク管理アプリです。

BusyMax는 일정, 할 일, 미리 알림, 일일 계획을 하나의 작업 공간에서 관리할 수 있는 Linux 데스크톱용 캘린더 및 할 일 관리 앱입니다.

-

BusyMax é um gestor de calendário e tarefas para o ambiente de trabalho Linux que reúne eventos, tarefas, lembretes e agendas diárias num único espaço de trabalho.

+

BusyMax é um gestor de calendário e tarefas para o ambiente de trabalho Linux que reúne eventos, tarefas, lembretes e agendas diárias num único espaço de trabalho.

BusyMax — календарь и планировщик задач для Linux. В одном приложении можно управлять событиями, задачами, напоминаниями и распорядком дня.

BusyMax là trình quản lý lịch và công việc cho máy tính Linux, giúp lập kế hoạch sự kiện, công việc, lời nhắc và lịch trình hằng ngày trong một không gian làm việc.

BusyMax 是一款面向 Linux 桌面的日历与任务管理工具,可在一个工作区中规划日程、任务、提醒和每日安排。

@@ -66,6 +66,9 @@ BusyStack io.busystack.busymax.desktop + + text/calendar + io.busystack.busymax Office @@ -91,6 +94,11 @@ + + +

Beta maintenance release.

+
+

Beta maintenance release.

diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index f7d00882..39e9e27d 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -18,6 +18,8 @@ constexpr char kNativeMenuChannel[] = "busymax/native_menus"; constexpr char kWindowChannel[] = "io.busystack.busymax/window"; constexpr char kHeaderBarChannel[] = "io.busystack.busymax/headerbar"; constexpr char kGtkSettingsChannel[] = "io.busystack.busymax/gtk_settings"; +constexpr char kExternalCalendarOpenChannel[] = + "io.busystack.busymax/external_calendar_open"; constexpr char kGtkFontSettingsEventChannel[] = "io.busystack.busymax/gtk_font_settings"; constexpr char kGtkThemeColorsEventChannel[] = @@ -98,6 +100,9 @@ struct _MyApplication { FlMethodChannel* window_channel; FlMethodChannel* header_bar_channel; FlMethodChannel* gtk_settings_channel; + FlMethodChannel* external_calendar_open_channel; + GQueue* pending_external_opens; + gboolean external_calendar_open_ready; FlEventChannel* gtk_font_settings_event_channel; FlEventChannel* gtk_theme_colors_event_channel; gulong gtk_font_settings_signal_id; @@ -203,6 +208,19 @@ struct _MyApplication { gint header_onboarding_content_width; }; +struct PendingExternalOpen { + gchar* kind; + gchar* value; +}; + +static void pending_external_open_free(gpointer data) { + auto* item = static_cast(data); + if (item == nullptr) return; + g_free(item->kind); + g_free(item->value); + g_free(item); +} + G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) static void schedule_header_bar_focus_state_refresh(MyApplication* self); @@ -1380,8 +1398,29 @@ struct NativeMenuSession { struct NativeMenuHandlerData { GtkWidget* view; NativeMenuSession* active; + GdkEvent* trigger_event; + gulong trigger_event_signal_id; }; +static void native_menu_event_after_cb(GtkWidget*, + GdkEvent* event, + gpointer user_data) { + switch (event->type) { + case GDK_BUTTON_PRESS: + case GDK_BUTTON_RELEASE: + case GDK_KEY_PRESS: + case GDK_KEY_RELEASE: + case GDK_TOUCH_BEGIN: + case GDK_TOUCH_END: + break; + default: + return; + } + auto* data = static_cast(user_data); + g_clear_pointer(&data->trigger_event, gdk_event_free); + data->trigger_event = gdk_event_copy(event); +} + static void native_menu_session_respond(NativeMenuSession* session, gint selected_index) { if (session->method_call == nullptr) { @@ -1461,6 +1500,12 @@ static void native_menu_action_activated_cb(GSimpleAction* action, GVariant*, gpointer user_data) { auto* session = static_cast(user_data); + g_autoptr(GVariant) state = g_action_get_state(G_ACTION(action)); + if (state != nullptr && + g_variant_is_of_type(state, G_VARIANT_TYPE_BOOLEAN)) { + g_simple_action_set_state( + action, g_variant_new_boolean(!g_variant_get_boolean(state))); + } session->pending_selected_index = GPOINTER_TO_INT( g_object_get_data(G_OBJECT(action), kNativeMenuActionIndexKey)) - @@ -1675,38 +1720,51 @@ static void show_native_menu(NativeMenuHandlerData* data, return; } - size_t selected_entry_count = 0; - gboolean has_disabled_entry = FALSE; + size_t radio_entry_count = 0; + size_t selected_radio_entry_count = 0; + gboolean has_disabled_radio_entry = FALSE; for (size_t index = 0; index < fl_value_get_length(entries); index++) { FlValue* entry = fl_value_get_list_value(entries, index); const gchar* label = fl_lookup_string_arg(entry, "label"); FlValue* icon = fl_value_lookup_string(entry, "icon"); FlValue* shortcut = fl_value_lookup_string(entry, "shortcut"); + const gchar* role = fl_lookup_string_arg(entry, "role"); gboolean enabled = TRUE; gboolean selected = FALSE; if (label == nullptr || (icon != nullptr && fl_value_get_type(icon) != FL_VALUE_TYPE_STRING) || (shortcut != nullptr && fl_value_get_type(shortcut) != FL_VALUE_TYPE_STRING) || + (role != nullptr && g_strcmp0(role, "command") != 0 && + g_strcmp0(role, "radio") != 0 && g_strcmp0(role, "toggle") != 0) || !fl_lookup_optional_bool_arg(entry, "enabled", TRUE, &enabled) || !fl_lookup_optional_bool_arg(entry, "selected", FALSE, &selected)) { respond_native_menu_argument_error( method_call, "each entry must contain a label, optional string icon and shortcut, " - "and optional boolean enabled and selected values."); + "a command, radio, or toggle role, and optional boolean enabled and " + "selected values."); + return; + } + const gboolean is_radio = g_strcmp0(role, "radio") == 0; + const gboolean is_toggle = g_strcmp0(role, "toggle") == 0; + if (selected && !is_radio && !is_toggle) { + respond_native_menu_argument_error( + method_call, "command entries cannot be selected."); return; } - if (selected) { - selected_entry_count++; + if (is_radio) { + radio_entry_count++; + selected_radio_entry_count += selected ? 1 : 0; + has_disabled_radio_entry = has_disabled_radio_entry || !enabled; } - has_disabled_entry = has_disabled_entry || !enabled; } - if (selected_entry_count > 1 || - (selected_entry_count == 1 && has_disabled_entry)) { + if ((radio_entry_count > 0 && selected_radio_entry_count != 1) || + has_disabled_radio_entry) { respond_native_menu_argument_error( method_call, - "single-choice menus require exactly one selected entry and all " - "entries enabled."); + "single-choice menus require exactly one selected radio entry and all " + "radio entries enabled."); return; } @@ -1727,13 +1785,14 @@ static void show_native_menu(NativeMenuHandlerData* data, GSimpleAction* selection_action = nullptr; g_autofree gchar* detailed_selection_action = nullptr; - if (selected_entry_count == 1) { + if (radio_entry_count > 0) { g_autofree gchar* selected_target = nullptr; for (size_t index = 0; index < fl_value_get_length(entries); index++) { FlValue* entry = fl_value_get_list_value(entries, index); gboolean selected = FALSE; fl_lookup_optional_bool_arg(entry, "selected", FALSE, &selected); - if (selected) { + if (selected && + g_strcmp0(fl_lookup_string_arg(entry, "role"), "radio") == 0) { selected_target = g_strdup_printf("%zu", index); break; } @@ -1755,17 +1814,24 @@ static void show_native_menu(NativeMenuHandlerData* data, const gchar* label = fl_lookup_string_arg(entry, "label"); const gchar* icon_name = fl_lookup_string_arg(entry, "icon"); const gchar* shortcut = fl_lookup_string_arg(entry, "shortcut"); + const gchar* role = fl_lookup_string_arg(entry, "role"); gboolean enabled = TRUE; + gboolean selected = FALSE; fl_lookup_optional_bool_arg(entry, "enabled", TRUE, &enabled); + fl_lookup_optional_bool_arg(entry, "selected", FALSE, &selected); g_autoptr(GMenuItem) item = g_menu_item_new(label, nullptr); - if (selection_action != nullptr) { + if (g_strcmp0(role, "radio") == 0) { g_autofree gchar* target = g_strdup_printf("%zu", index); g_menu_item_set_action_and_target_value( item, detailed_selection_action, g_variant_new_string(target)); } else { g_autofree gchar* action_name = g_strdup_printf("select-%zu", index); - GSimpleAction* action = g_simple_action_new(action_name, nullptr); + GSimpleAction* action = g_strcmp0(role, "toggle") == 0 + ? g_simple_action_new_stateful( + action_name, nullptr, + g_variant_new_boolean(selected)) + : g_simple_action_new(action_name, nullptr); g_simple_action_set_enabled(action, enabled); g_object_set_data(G_OBJECT(action), kNativeMenuActionIndexKey, GINT_TO_POINTER(static_cast(index) + 1)); @@ -1826,10 +1892,16 @@ static void show_native_menu(NativeMenuHandlerData* data, // widget whose GdkWindow belongs to the toplevel. Anchor to the translated // rectangle directly: moving a hidden proxy widget would only queue a later // size allocation, so an immediate popup would still see its old position. + g_autoptr(GdkEvent) current_event = gtk_get_current_event(); + const GdkEvent* trigger_event = current_event != nullptr + ? current_event + : data->trigger_event; gtk_menu_popup_at_rect( GTK_MENU(session->menu), rect_window, &window_anchor, open_above ? GDK_GRAVITY_NORTH_WEST : GDK_GRAVITY_SOUTH_WEST, - open_above ? GDK_GRAVITY_SOUTH_WEST : GDK_GRAVITY_NORTH_WEST, nullptr); + open_above ? GDK_GRAVITY_SOUTH_WEST : GDK_GRAVITY_NORTH_WEST, + trigger_event); + g_clear_pointer(&data->trigger_event, gdk_event_free); if (focus_first) { gtk_menu_shell_select_first(GTK_MENU_SHELL(session->menu), TRUE); } else { @@ -1844,10 +1916,15 @@ static void native_menu_handler_data_free(gpointer user_data) { native_menu_session_dispose(data->active); } if (data->view != nullptr) { + if (data->trigger_event_signal_id != 0) { + g_signal_handler_disconnect(data->view, + data->trigger_event_signal_id); + } g_object_remove_weak_pointer( G_OBJECT(data->view), reinterpret_cast(&data->view)); } + g_clear_pointer(&data->trigger_event, gdk_event_free); g_free(data); } @@ -1882,6 +1959,8 @@ static FlMethodChannel* create_native_menu_channel(FlView* view) { data->view = GTK_WIDGET(view); g_object_add_weak_pointer(G_OBJECT(data->view), reinterpret_cast(&data->view)); + data->trigger_event_signal_id = g_signal_connect( + data->view, "event-after", G_CALLBACK(native_menu_event_after_cb), data); fl_method_channel_set_method_call_handler( channel, native_menu_method_call_cb, data, native_menu_handler_data_free); @@ -4834,6 +4913,77 @@ static void window_method_call_cb(FlMethodChannel* channel, } } +static void flush_external_calendar_opens(MyApplication* self) { + if (!self->external_calendar_open_ready || + self->external_calendar_open_channel == nullptr || + self->pending_external_opens == nullptr) { + return; + } + while (!g_queue_is_empty(self->pending_external_opens)) { + auto* item = static_cast( + g_queue_pop_head(self->pending_external_opens)); + g_autoptr(FlValue) args = fl_value_new_map(); + fl_value_set_string_take(args, "kind", fl_value_new_string(item->kind)); + fl_value_set_string_take(args, "value", fl_value_new_string(item->value)); + fl_method_channel_invoke_method(self->external_calendar_open_channel, + "openItem", args, nullptr, nullptr, + nullptr); + pending_external_open_free(item); + } +} + +static void external_calendar_open_method_call_cb(FlMethodChannel*, + FlMethodCall* method_call, + gpointer user_data) { + auto* self = MY_APPLICATION(user_data); + const gchar* method = fl_method_call_get_name(method_call); + if (g_strcmp0(method, "ready") != 0) { + fl_method_call_respond_not_implemented(method_call, nullptr); + return; + } + self->external_calendar_open_ready = TRUE; + fl_method_call_respond_success(method_call, nullptr, nullptr); + flush_external_calendar_opens(self); +} + +static void register_external_calendar_open_channel(MyApplication* self, + FlView* view) { + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + self->external_calendar_open_channel = fl_method_channel_new( + fl_engine_get_binary_messenger(fl_view_get_engine(view)), + kExternalCalendarOpenChannel, FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler( + self->external_calendar_open_channel, + external_calendar_open_method_call_cb, self, nullptr); +} + +static void queue_external_calendar_open(MyApplication* self, + const gchar* kind, + const gchar* value) { + if (kind == nullptr || value == nullptr || value[0] == '\0') return; + auto* item = g_new0(PendingExternalOpen, 1); + item->kind = g_strdup(kind); + item->value = g_strdup(value); + g_queue_push_tail(self->pending_external_opens, item); + flush_external_calendar_opens(self); +} + +static gboolean queue_supported_external_file(MyApplication* self, + GFile* file) { + g_autofree gchar* uri = g_file_get_uri(file); + if (uri != nullptr && g_ascii_strncasecmp(uri, "webcal:", 7) == 0) { + queue_external_calendar_open(self, "webcal", uri); + return TRUE; + } + if (!g_file_is_native(file)) return FALSE; + g_autofree gchar* path = g_file_get_path(file); + if (path == nullptr) return FALSE; + g_autofree gchar* lower = g_ascii_strdown(path, -1); + if (!g_str_has_suffix(lower, ".ics")) return FALSE; + queue_external_calendar_open(self, "ics", path); + return TRUE; +} + static void register_window_channel(MyApplication* self, FlView* view) { g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); self->window_channel = fl_method_channel_new( @@ -4912,6 +5062,7 @@ static void my_application_activate(GApplication* application) { register_native_date_time_picker(self, view, window); register_native_dialogs(self, view, window); register_native_menus(self, view); + register_external_calendar_open_channel(self, view); register_window_channel(self, view); register_header_bar_channel(self, view); register_gtk_settings_channel(self, view); @@ -4920,19 +5071,49 @@ static void my_application_activate(GApplication* application) { schedule_header_bar_focus_state_refresh(self); } +// Implements GApplication::open. +static void my_application_open(GApplication* application, + GFile** files, + gint file_count, + const gchar*) { + MyApplication* self = MY_APPLICATION(application); + gboolean accepted = FALSE; + for (gint index = 0; index < file_count; index++) { + accepted = queue_supported_external_file(self, files[index]) || accepted; + } + if (!accepted) { + if (self->main_window == nullptr) { + g_application_activate(application); + } + return; + } + self->start_minimized = FALSE; + if (self->main_window == nullptr) { + g_application_activate(application); + } else { + restore_main_window(self); + } +} + // Implements GApplication::local_command_line. static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { MyApplication* self = MY_APPLICATION(application); - // Strip out the first argument as it is the binary name. - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); self->start_minimized = FALSE; + g_autoptr(GPtrArray) files = g_ptr_array_new_with_free_func(g_object_unref); for (gchar** argument = *arguments + 1; *argument != nullptr; argument++) { if (g_strcmp0(*argument, "--start-minimized") == 0) { self->start_minimized = TRUE; - break; + continue; } + if ((*argument)[0] == '-') continue; + g_ptr_array_add(files, g_file_new_for_commandline_arg(*argument)); + } + self->dart_entrypoint_arguments = g_new0(gchar*, 2); + if (self->start_minimized) { + self->dart_entrypoint_arguments[0] = g_strdup("--start-minimized"); } g_autoptr(GError) error = nullptr; @@ -4942,7 +5123,12 @@ static gboolean my_application_local_command_line(GApplication* application, return TRUE; } - g_application_activate(application); + if (files->len > 0) { + g_application_open( + application, reinterpret_cast(files->pdata), files->len, ""); + } else { + g_application_activate(application); + } *exit_status = 0; return TRUE; @@ -4979,6 +5165,7 @@ static void my_application_dispose(GObject* object) { g_clear_object(&self->window_channel); g_clear_object(&self->header_bar_channel); g_clear_object(&self->gtk_settings_channel); + g_clear_object(&self->external_calendar_open_channel); disconnect_gtk_font_settings_signal(self); g_clear_object(&self->gtk_font_settings_event_channel); g_clear_object(&self->gtk_theme_colors_event_channel); @@ -5068,11 +5255,17 @@ static void my_application_dispose(GObject* object) { g_clear_pointer(&self->header_keyboard_shortcuts_shortcut, g_free); g_clear_pointer(&self->header_search_query, g_free); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + if (self->pending_external_opens != nullptr) { + g_queue_free_full( + self->pending_external_opens, pending_external_open_free); + self->pending_external_opens = nullptr; + } G_OBJECT_CLASS(my_application_parent_class)->dispose(object); } static void my_application_class_init(MyApplicationClass* klass) { G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->open = my_application_open; G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; G_APPLICATION_CLASS(klass)->startup = my_application_startup; @@ -5088,6 +5281,9 @@ static void my_application_init(MyApplication* self) { self->window_channel = nullptr; self->header_bar_channel = nullptr; self->gtk_settings_channel = nullptr; + self->external_calendar_open_channel = nullptr; + self->pending_external_opens = g_queue_new(); + self->external_calendar_open_ready = FALSE; self->gtk_font_settings_event_channel = nullptr; self->gtk_theme_colors_event_channel = nullptr; self->gtk_font_settings_signal_id = 0; @@ -5205,6 +5401,6 @@ MyApplication* my_application_new() { return MY_APPLICATION(g_object_new(my_application_get_type(), "application-id", APPLICATION_ID, "flags", - static_cast(0), + G_APPLICATION_HANDLES_OPEN, nullptr)); } diff --git a/pubspec.lock b/pubspec.lock index 5f21ce71..34f28676 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -193,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + console: + dependency: transitive + description: + name: console + sha256: e04e7824384c5b39389acdd6dc7d33f3efe6b232f6f16d7626f194f6a01ad69a + url: "https://pub.dev" + source: hosted + version: "4.1.0" convert: dependency: transitive description: @@ -273,6 +281,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.33.0" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" fake_async: dependency: transitive description: @@ -377,16 +393,27 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fluent_ui: + dependency: "direct main" + description: + name: fluent_ui + sha256: "64c2b9dd208f8e3134d3e38db107235ee30c1dd9b792843274687a7ecc063590" + url: "https://pub.dev" + source: hosted + version: "4.16.1" + fluentui_system_icons: + dependency: "direct main" + description: + name: fluentui_system_icons + sha256: "1592ae32dae75fb66741ab2c0607bc6554ef086f1c457e27ef937fb4901d33da" + url: "https://pub.dev" + source: hosted + version: "1.1.273" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" - flutter_driver: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -395,6 +422,46 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "1447ba911c60f2ba3f25dae1af151ec187162566b0f57e37771bf0b400f013ad" + url: "https://pub.dev" + source: hosted + version: "22.3.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" + url: "https://pub.dev" + source: hosted + version: "8.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "43c3761d916c9bd3d5c7ebbc44d82f4990329840c0c5d62ad5260cc1b5d399bd" + url: "https://pub.dev" + source: hosted + version: "12.2.0" + flutter_local_notifications_web: + dependency: transitive + description: + name: flutter_local_notifications_web + sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945" + url: "https://pub.dev" + source: hosted + version: "3.1.1" flutter_localizations: dependency: "direct main" description: flutter @@ -461,16 +528,26 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_timezone: + dependency: "direct main" + description: + path: "third_party/flutter_timezone" + relative: true + source: path + version: "5.1.0" flutter_web_plugins: dependency: transitive description: flutter source: sdk version: "0.0.0" - fuchsia_remote_debug_protocol: + get_it: dependency: transitive - description: flutter - source: sdk - version: "0.0.0" + description: + name: get_it + sha256: "568d62f0e68666fb5d95519743b3c24a34c7f19d834b0658c46e26d778461f66" + url: "https://pub.dev" + source: hosted + version: "9.2.1" glob: dependency: transitive description: @@ -544,7 +621,7 @@ packages: source: hosted version: "4.1.2" image: - dependency: transitive + dependency: "direct dev" description: name: image sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce @@ -559,11 +636,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.5" - integration_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" intl: dependency: "direct main" description: @@ -660,6 +732,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + math_expressions: + dependency: transitive + description: + name: math_expressions + sha256: "22aca3a24e445be5f42ecf186cbded1f9951bf9ec5eb48fcddbd55e102bb11f5" + url: "https://pub.dev" + source: hosted + version: "3.2.0" matrix4_transform: dependency: transitive description: @@ -668,6 +748,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.1" + menu_base: + dependency: transitive + description: + name: menu_base + sha256: "820368014a171bd1241030278e6c2617354f492f5c703d7b7d4570a6b8b84405" + url: "https://pub.dev" + source: hosted + version: "0.1.1" meta: dependency: transitive description: @@ -692,6 +780,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + msix: + dependency: "direct dev" + description: + name: msix + sha256: "61415c352e8aea084332b8a5514e6408c961c8cdeca202804fff1040eb555ac7" + url: "https://pub.dev" + source: hosted + version: "3.18.0" native_toolchain_c: dependency: transitive description: @@ -852,14 +948,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" - process: - dependency: transitive - description: - name: process - sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 - url: "https://pub.dev" - source: hosted - version: "5.0.5" pub_semver: dependency: transitive description: @@ -900,46 +988,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" - screen_retriever: - dependency: transitive - description: - name: screen_retriever - sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_linux: - dependency: transitive - description: - name: screen_retriever_linux - sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_macos: - dependency: transitive - description: - name: screen_retriever_macos - sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_platform_interface: - dependency: transitive - description: - name: screen_retriever_platform_interface - sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_windows: + scroll_pos: dependency: transitive description: - name: screen_retriever_windows - sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + name: scroll_pos + sha256: cebf602b2dd939de6832bb902ffefb574608d1b84f420b82b381a4007d3c1e1b url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.5.0" shelf: dependency: transitive description: @@ -956,6 +1012,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + shortid: + dependency: transitive + description: + name: shortid + sha256: d0b40e3dbb50497dad107e19c54ca7de0d1a274eb9b4404991e443dadb9ebedb + url: "https://pub.dev" + source: hosted + version: "0.1.2" sky_engine: dependency: transitive description: flutter @@ -1041,14 +1105,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - sync_http: - dependency: transitive - description: - name: sync_http - sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" - url: "https://pub.dev" - source: hosted - version: "0.3.1" system_theme: dependency: "direct main" description: @@ -1089,6 +1145,13 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.1" + tray_manager: + dependency: "direct main" + description: + path: "third_party/tray_manager" + relative: true + source: path + version: "0.5.3" typed_data: dependency: transitive description: @@ -1233,14 +1296,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" - webdriver: - dependency: transitive - description: - name: webdriver - sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" - url: "https://pub.dev" - source: hosted - version: "3.1.0" win32: dependency: transitive description: @@ -1249,14 +1304,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.3.0" - window_manager: - dependency: transitive - description: - name: window_manager - sha256: "7eb6d6c4164ec08e1bf978d6e733f3cebe792e2a23fb07cbca25c2872bfdbdcd" - url: "https://pub.dev" - source: hosted - version: "0.5.1" xdg_directories: dependency: transitive description: @@ -1297,12 +1344,11 @@ packages: source: hosted version: "10.2.0" yaru_window: - dependency: transitive + dependency: "direct overridden" description: - name: yaru_window - sha256: "58539a9abe9901891dadce142c7a5d303920b780dd0f7bd21f076a80adeeb744" - url: "https://pub.dev" - source: hosted + path: "third_party/yaru_window" + relative: true + source: path version: "0.2.2" yaru_window_linux: dependency: transitive @@ -1312,14 +1358,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" - yaru_window_manager: - dependency: transitive - description: - name: yaru_window_manager - sha256: "6288fd6ccd8bb9d5be56073c6f277e2da08dd38e346507bf88bc9332b08dd180" - url: "https://pub.dev" - source: hosted - version: "0.1.3" yaru_window_platform_interface: dependency: transitive description: @@ -1328,14 +1366,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.3" - yaru_window_web: - dependency: transitive - description: - name: yaru_window_web - sha256: cf4d79e0760fbdcb78d4bbca3b5563f99518629224b9e5611f0ebd592befe1d9 - url: "https://pub.dev" - source: hosted - version: "0.0.4" sdks: dart: ">=3.12.0 <4.0.0" flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index b4ba8db2..d415edd5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymax description: BusyMax calendar and task manager. publish_to: 'none' -version: 0.1.6 +version: 0.1.7 environment: sdk: ^3.12.0 @@ -19,8 +19,12 @@ dependencies: desktop_notifications: ^0.6.3 drift: ^2.33.0 file_selector: ^1.0.3 + fluent_ui: 4.16.1 + fluentui_system_icons: 1.1.273 flutter_riverpod: ^2.6.0 + flutter_local_notifications: 22.3.0 flutter_secure_storage: ^10.3.0 + flutter_timezone: 5.1.0 go_router: ^17.3.0 http: ^1.6.0 infinite_calendar_view: ^2.11.5 @@ -34,6 +38,7 @@ dependencies: sqlite3_flutter_libs: ^0.6.0 system_theme: ^3.3.0 timezone: ^0.11.1 + tray_manager: 0.5.3 ubuntu_localizations: ^0.5.2+3 url_launcher: ^6.3.0 uuid: ^4.5.0 @@ -44,15 +49,21 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - integration_test: - sdk: flutter + image: 4.8.0 build_runner: ^2.15.0 drift_dev: ^2.33.0 flutter_lints: ^6.0.0 mocktail: ^1.0.0 + msix: 3.18.0 very_good_analysis: ^10.1.0 dependency_overrides: + flutter_timezone: + path: third_party/flutter_timezone + tray_manager: + path: third_party/tray_manager + yaru_window: + path: third_party/yaru_window xdg_status_notifier_item: path: third_party/xdg_status_notifier_item @@ -63,6 +74,8 @@ flutter: - assets/branding/busymax-logo.svg - assets/branding/busymax-logo-offline.svg - assets/branding/busymax-logo.png + - assets/windows/busymax_tray.ico + - assets/windows/busymax_tray_offline.ico # Do not add google_sign_in for Linux OAuth. It does not support Linux. # Do not add extension_google_sign_in_as_googleapis_auth for Linux OAuth. diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index c55c277f..3b1b5b57 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymax title: BusyMax -version: "0.1.6" +version: "0.1.7" summary: Calendar and task manager description: | BusyMax is a Linux desktop calendar and task manager. diff --git a/test/app/about_dialog_test.dart b/test/app/about_dialog_test.dart index 3074e993..3d4738c4 100644 --- a/test/app/about_dialog_test.dart +++ b/test/app/about_dialog_test.dart @@ -66,7 +66,7 @@ void main() { tester.getSize(find.byType(YaruWindowControl)), const Size.square(kYaruWindowControlSize), ); - }); + }, skip: !Platform.isLinux); testWidgets( 'informational titlebar retains its semantic high-contrast divider', @@ -224,7 +224,7 @@ void main() { ); for (final brightness in Brightness.values) { - testWidgets('about version badge uses the shared neutral outlined pill in ' + testWidgets('about version badge uses the active accent pill in ' '$brightness', (tester) async { const accent = Color(0xFF3584E4); _setPackageInfo(version: '1.2.3', buildNumber: ''); @@ -240,7 +240,6 @@ void main() { expect(find.text('v1.2.3'), findsOneWidget); expect(find.text('v1.2.3+'), findsNothing); - final colors = theme.extension()!; final badgeFinder = find.byWidgetPredicate( (widget) => widget is DecoratedBox && @@ -250,12 +249,15 @@ void main() { ); final badge = tester.widget(badgeFinder); final decoration = badge.decoration as BoxDecoration; - expect(decoration.color, colors.control); + expect(decoration.color, theme.colorScheme.primary); expect( decoration.borderRadius, BorderRadius.circular(BusyMaxRadius.pill), ); - expect((decoration.border! as Border).top.color, colors.divider); + expect( + (decoration.border! as Border).top.color, + theme.colorScheme.primary, + ); expect( find.ancestor( of: find.text('v1.2.3'), @@ -284,7 +286,7 @@ void main() { ); final versionText = tester.widget(find.text('v1.2.3')); final textColor = versionText.style!.color!; - expect(textColor, colors.foreground); + expect(textColor, theme.colorScheme.onPrimary); expect(versionText.style?.fontWeight, FontWeight.w600); }); } diff --git a/test/app/app_bootstrap_provider_test.dart b/test/app/app_bootstrap_provider_test.dart index e6a73efd..730779f4 100644 --- a/test/app/app_bootstrap_provider_test.dart +++ b/test/app/app_bootstrap_provider_test.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:busymax/src/core/secrets/secret_store.dart'; +import 'package:busymax/src/dav/dav_provider_profile.dart'; import 'package:drift/native.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -8,11 +10,15 @@ import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/auth/data/auth_repository.dart'; +import 'package:busymax/src/features/notifications/notification_scheduler.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import 'package:busymax/src/google_tasks/api/google_tasks_api_surface.dart'; import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:drift/drift.dart' show Value; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; void main() { test('repositories are not created without an active account', () async { @@ -204,6 +210,255 @@ void main() { expect(account.authState, accountAuthStateReauthRequired); }, ); + + test( + 'production DAV sync rebuilds schedules before checking notifications', + () async { + final database = AppDatabase(NativeDatabase.memory()); + final secrets = InMemorySecretStore(); + await _seedNextcloudNotificationAccount(database, secrets); + final eventStartUtc = DateTime.now().toUtc().add(const Duration(days: 2)); + var syncReports = 0; + final client = MockClient((request) async { + if (request.method == 'REPORT' && + request.body.contains('sync-collection')) { + syncReports += 1; + if (syncReports == 1) { + return http.Response( + _davSyncResponse(token: 'token-1', etag: '"v1"'), + 207, + ); + } + return http.Response( + _davSyncResponse(token: 'token-2', deleted: true), + 207, + ); + } + if (request.method == 'REPORT' && + request.body.contains('calendar-multiget')) { + return http.Response(_davMultigetResponse(eventStartUtc), 207); + } + fail('Unexpected ${request.method} ${request.url}'); + }); + final scheduler = _TrackingNotificationScheduler(database); + final container = ProviderContainer( + overrides: [ + databaseProvider.overrideWithValue(database), + secretStoreProvider.overrideWithValue(secrets), + baseHttpClientProvider.overrideWithValue(client), + notificationSchedulerProvider.overrideWithValue(scheduler), + ], + ); + addTearDown(() async { + container.dispose(); + client.close(); + await database.close(); + }); + + await container + .read(davAccountSyncEngineFactoryProvider)('nextcloud-account') + .synchronize(); + + expect(syncReports, 1); + final objects = await database.select(database.davObjects).get(); + expect(objects, hasLength(1)); + expect(objects.single.lastParseStatus, 'parsed'); + final events = await database.select(database.calendarEvents).get(); + expect(events, hasLength(1)); + expect(events.single.remindersJson, contains('DISPLAY')); + var schedules = await database + .select(database.notificationSchedule) + .get(); + expect(schedules, hasLength(1)); + expect(schedules.single.sourceType, 'event'); + expect(schedules.single.title, 'Remote reminder'); + expect(scheduler.scheduleCountsAtCheck, [1]); + + await container + .read(davAccountSyncEngineFactoryProvider)('nextcloud-account') + .synchronize(); + + schedules = await database.select(database.notificationSchedule).get(); + expect(schedules, isEmpty); + expect(scheduler.scheduleCountsAtCheck, [1, 0]); + }, + ); +} + +const _nextcloudCollectionHref = '/cloud/remote.php/dav/calendars/alex/work/'; +const _nextcloudEventHref = '${_nextcloudCollectionHref}reminder.ics'; + +Future _seedNextcloudNotificationAccount( + AppDatabase database, + InMemorySecretStore secrets, +) async { + final now = DateTime.now().toUtc(); + final timestamp = now.toIso8601String(); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'nextcloud-account', + provider: 'nextcloud', + authority: 'https://cloud.example.test/cloud', + providerAccountId: 'alex', + credentialKind: 'nextcloud_app_password', + authState: const Value('signed_in'), + createdAtUtc: timestamp, + updatedAtUtc: timestamp, + ), + ); + await secrets.saveCredential( + 'nextcloud-account', + NextcloudSecretRecord( + canonicalServer: Uri.parse('https://cloud.example.test/cloud'), + loginName: 'alex', + appPassword: 'secret-app-password', + ), + ); + await database + .into(database.davAccountServices) + .insert( + DavAccountServicesCompanion.insert( + accountId: 'nextcloud-account', + providerProfileVersion: const Value(davProviderProfileVersion), + canonicalServiceUri: + 'https://cloud.example.test/cloud/remote.php/dav/', + canonicalOrigin: 'https://cloud.example.test', + principalHref: const Value( + 'https://cloud.example.test/cloud/remote.php/dav/principals/users/alex/', + ), + calendarHomeHref: const Value( + 'https://cloud.example.test/cloud/remote.php/dav/calendars/alex/', + ), + capabilitiesJson: const Value( + '{"hasPrincipal":true,"hasCalendarHome":true}', + ), + discoveredAtUtc: timestamp, + lastValidatedAtUtc: Value(timestamp), + ), + ); + await database + .into(database.davCollections) + .insert( + DavCollectionsCompanion.insert( + id: 'nextcloud-collection', + accountId: 'nextcloud-account', + hrefKey: _nextcloudCollectionHref, + requestUri: 'https://cloud.example.test$_nextcloudCollectionHref', + displayName: 'Work', + supportedComponentMask: const Value(1), + supportedReportsJson: const Value( + '["{DAV:}sync-collection",' + '"{urn:ietf:params:xml:ns:caldav}calendar-multiget"]', + ), + currentUserPrivilegesJson: const Value( + '["{DAV:}read","{DAV:}write"]', + ), + readOnly: const Value(false), + eventProjectionEnabled: const Value(true), + lastInventoryAtUtc: Value(timestamp), + createdAtUtc: timestamp, + updatedAtUtc: timestamp, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'dav-calendar-nextcloud-collection', + accountId: 'nextcloud-account', + provider: 'nextcloud', + providerCalendarId: _nextcloudCollectionHref, + davCollectionId: const Value('nextcloud-collection'), + summary: 'Work', + createdAtLocal: now.millisecondsSinceEpoch, + updatedAtLocal: now.millisecondsSinceEpoch, + ), + ); +} + +String _davSyncResponse({ + required String token, + String? etag, + bool deleted = false, +}) => + ''' + + ${etag == null && !deleted ? '' : ''' + $_nextcloudEventHref + ${deleted ? 'HTTP/1.1 404 Not Found' : '''$etag + HTTP/1.1 200 OK'''} + '''} + $token +'''; + +String _davMultigetResponse(DateTime eventStartUtc) { + final start = _icalUtc(eventStartUtc); + final end = _icalUtc(eventStartUtc.add(const Duration(hours: 1))); + return ''' + + + $_nextcloudEventHref + + "v1" + + HTTP/1.1 200 OK + +'''; +} + +String _icalUtc(DateTime value) { + final utc = value.toUtc(); + String twoDigits(int part) => part.toString().padLeft(2, '0'); + return '${utc.year.toString().padLeft(4, '0')}' + '${twoDigits(utc.month)}${twoDigits(utc.day)}T' + '${twoDigits(utc.hour)}${twoDigits(utc.minute)}' + '${twoDigits(utc.second)}Z'; +} + +final class _TrackingNotificationScheduler implements NotificationScheduler { + _TrackingNotificationScheduler(this.database); + + final AppDatabase database; + final List scheduleCountsAtCheck = []; + + @override + Future checkNow() async { + scheduleCountsAtCheck.add( + await database + .select(database.notificationSchedule) + .get() + .then((rows) => rows.length), + ); + } + + @override + Future handleActivation({ + required String notificationScheduleId, + required String action, + }) async {} + + @override + void start() {} + + @override + void stop() {} } Future _seedSignedInGoogleAccount(AppDatabase database) { diff --git a/test/app/busymax_menu_button_test.dart b/test/app/busymax_menu_button_test.dart index f0a53f42..d6deb14e 100644 --- a/test/app/busymax_menu_button_test.dart +++ b/test/app/busymax_menu_button_test.dart @@ -251,10 +251,15 @@ void main() { value: 'refresh', label: 'Refresh calendar', icon: YaruIcons.refresh, + role: BusyMaxMenuEntryRole.radio, selected: true, shortcut: 'Ctrl+R', ), - BusyMaxMenuEntry(value: 'open', label: 'Open in provider'), + BusyMaxMenuEntry( + value: 'open', + label: 'Open in provider', + role: BusyMaxMenuEntryRole.radio, + ), ], onSelected: (value) => selected = value, ), @@ -286,13 +291,124 @@ void main() { 'label': 'Refresh calendar', 'icon': 'view-refresh-symbolic', 'enabled': true, + 'role': 'radio', 'selected': true, 'shortcut': 'Ctrl+R', }, - {'label': 'Open in provider', 'enabled': true, 'selected': false}, + { + 'label': 'Open in provider', + 'enabled': true, + 'role': 'radio', + 'selected': false, + }, ]); }); + testWidgets('toggle entries remain valid beside disabled calendar commands', ( + tester, + ) async { + MethodCall? nativeCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + nativeCall = call; + return null; + }); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: BusyMaxMenuButton( + tooltip: 'Options', + nativeMenuService: const NativeMenuService(channel: channel), + entries: const [ + BusyMaxMenuEntry(value: 'refresh', label: 'Refresh calendar'), + BusyMaxMenuEntry( + value: 'reminders', + label: 'Event reminders', + role: BusyMaxMenuEntryRole.toggle, + selected: true, + ), + BusyMaxMenuEntry( + value: 'delete', + label: 'Delete', + enabled: false, + ), + ], + onSelected: (_) {}, + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + final entries = + (nativeCall!.arguments as Map)['entries']! + as List; + expect(entries[1], { + 'label': 'Event reminders', + 'enabled': true, + 'role': 'toggle', + 'selected': true, + }); + expect(entries[2], { + 'label': 'Delete', + 'enabled': false, + 'role': 'command', + 'selected': false, + }); + }); + + testWidgets('fallback renders toggle state independently of commands', ( + tester, + ) async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => throw MissingPluginException(), + ); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: BusyMaxMenuButton( + tooltip: 'Options', + nativeMenuService: const NativeMenuService(channel: channel), + entries: const [ + BusyMaxMenuEntry(value: 'refresh', label: 'Refresh calendar'), + BusyMaxMenuEntry( + value: 'reminders', + label: 'Event reminders', + role: BusyMaxMenuEntryRole.toggle, + selected: true, + ), + BusyMaxMenuEntry( + value: 'delete', + label: 'Delete', + enabled: false, + ), + ], + onSelected: (_) {}, + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(YaruRadio), findsNothing); + expect(find.byType(YaruCheckbox), findsOneWidget); + expect( + tester.widget(find.byType(YaruCheckbox)).value, + isTrue, + ); + expect(find.text('Delete'), findsOneWidget); + }); + testWidgets('controller dismissal carries the owned native session', ( tester, ) async { diff --git a/test/app/keyboard_shortcuts_dialog_test.dart b/test/app/keyboard_shortcuts_dialog_test.dart index ab1c95d4..92e17793 100644 --- a/test/app/keyboard_shortcuts_dialog_test.dart +++ b/test/app/keyboard_shortcuts_dialog_test.dart @@ -85,7 +85,7 @@ void main() { 'F9', ].map((label) => tester.getTopRight(find.text(label)).dx).toList(); expect(badgeEnds.every((end) => end == badgeEnds.first), isTrue); - }); + }, skip: !Platform.isLinux); for (final textScale in [1.0, 2.0]) { testWidgets('keyboard shortcuts remain scrollable in a short window at ' diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart index 0069f9ef..bac516cd 100644 --- a/test/app/localization_audit_test.dart +++ b/test/app/localization_audit_test.dart @@ -5,6 +5,8 @@ import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:busymax/l10n/generated/app_localizations_ar.dart'; import 'package:busymax/l10n/generated/app_localizations_fa.dart'; import 'package:busymax/src/l10n/app_locale.dart'; +import 'package:busymax/src/features/recurrence/domain/recurrence_rule.dart'; +import 'package:busymax/src/features/recurrence/presentation/recurrence_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -34,6 +36,7 @@ void main() { final templateFile = File('lib/l10n/app_en.arb'); final templateArb = _decodeArb(templateFile); final templateMessages = _messages(templateArb); + const allowedEmptyTranslationKeys = {'repeatSummarySeparator'}; final failures = []; for (final file in _translatedArbFiles()) { @@ -52,14 +55,70 @@ void main() { } for (final key in templateKeys.intersection(translatedKeys)) { final translation = messages[key]!; - if (translation.trim().isEmpty) { + if (translation.trim().isEmpty && + !allowedEmptyTranslationKeys.contains(key)) { failures.add('$path: $key has an empty translation'); } - for (final placeholder in _declaredPlaceholders(templateArb, key)) { + final declaredPlaceholders = _declaredPlaceholders( + templateArb, + key, + ).toSet(); + for (final placeholder in declaredPlaceholders) { if (!translation.contains('{$placeholder')) { failures.add('$path: $key does not use {$placeholder}'); } } + for (final placeholder in _usedPlaceholders(translation)) { + if (!declaredPlaceholders.contains(placeholder)) { + failures.add('$path: $key adds {$placeholder}'); + } + } + } + } + + expect(failures, isEmpty, reason: failures.join('\n')); + }); + + test('translated ARB catalogs do not silently copy English messages', () { + final template = _decodeArb(File('lib/l10n/app_en.arb')); + final templateMessages = _messages(template); + const allowedIdenticalKeys = { + 'appTitle', + 'apacheLicenseName', + 'dateTimeDisplay', + 'etag', + 'formatBoldShortLabel', + 'formatItalicShortLabel', + 'formatUnderlineShortLabel', + 'googleProvider', + 'googleTasksApi', + 'googleTasksProvider', + 'id', + 'microsoftProvider', + 'microsoftTodoProvider', + 'nextcloudProvider', + 'appleICloudTasksProvider', + 'taskUrl', + 'repeatSummarySeparator', + 'repeatMonthDayValue', + 'repeatWeekdayListStart', + 'repeatMonthDayListStart', + 'repeatYearlyMonthDayListStart', + 'repeatYearlyMonthListStart', + 'repeatYearlyMonthValue', + 'settingsSystem', + 'sensitivityNormal', + 'sensitivityPersonal', + }; + final failures = []; + + for (final file in _translatedArbFiles()) { + final messages = _messages(_decodeArb(file)); + for (final key in templateMessages.keys) { + if (allowedIdenticalKeys.contains(key)) continue; + if (messages[key] == templateMessages[key]) { + failures.add('${file.path}: $key copies the English value'); + } } } @@ -67,7 +126,7 @@ void main() { }); test('discard-changes action is distinct from cancel in every locale', () { - for (final locale in AppLocalizations.supportedLocales) { + for (final locale in busyMaxSupportedLocales) { final localizations = lookupAppLocalizations(locale); expect( localizations.discardChangesAction, @@ -150,7 +209,7 @@ void main() { 'it': 'Gestore di calendari e attività', 'ja': 'カレンダー・タスク管理アプリ', 'ko': '캘린더와 할 일 관리', - 'pt': 'Gestor de calendário e tarefas', + 'pt_PT': 'Gestor de calendário e tarefas', 'ru': 'Календарь и планировщик задач', 'vi': 'Trình quản lý lịch và công việc', 'zh': '日历与任务管理工具', @@ -179,13 +238,103 @@ void main() { ); }); - test('Portuguese is generated and exposed as a supported locale', () { - const locale = Locale('pt'); - final localizations = lookupAppLocalizations(locale); + test( + 'European Portuguese is generated and exposed without a generic alias', + () { + const locale = Locale('pt', 'PT'); + final localizations = lookupAppLocalizations(locale); - expect(AppLocalizations.supportedLocales, contains(locale)); - expect(localizations.settings, 'Definições'); - expect(localizations.today, 'Hoje'); + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(busyMaxSupportedLocales, contains(locale)); + expect(busyMaxSupportedLocales, isNot(contains(const Locale('pt')))); + expect(busyMaxLocaleFromTag('pt-PT'), locale); + expect(busyMaxLocaleFromTag('pt'), isNull); + expect(busyMaxLocaleFromTag('pt-BR'), isNull); + expect(_messages(_decodeArb(File('lib/l10n/app_pt.arb'))), isEmpty); + expect( + resolveBusyMaxLocales(const [ + Locale('pt', 'BR'), + ], busyMaxSupportedLocales), + const Locale('en'), + ); + expect(localizations.settings, 'Definições'); + expect(localizations.today, 'Hoje'); + }, + ); + + test( + 'reviewed Arabic conflict wording and Portuguese variant stay stable', + () { + final arabic = AppLocalizationsAr(); + expect( + arabic.remoteChangedAt('14:30'), + contains('تم التغيير على الخادم في:'), + ); + + final portuguese = lookupAppLocalizations(const Locale('pt', 'PT')); + expect(portuguese.settings, 'Definições'); + expect(portuguese.deleteTask, 'Eliminar tarefa'); + expect(portuguese.copyAndDelete, 'Copiar e eliminar'); + + final portugueseArb = File( + 'lib/l10n/app_pt_PT.arb', + ).readAsStringSync().toLowerCase(); + for (final brazilianForm in [ + 'excluído', + 'excluir', + 'compartilhada', + 'somente', + 'ele aparecerá', + ]) { + expect( + portugueseArb, + isNot(contains(brazilianForm)), + reason: 'pt-PT must not reintroduce $brazilianForm', + ); + } + }, + ); + + test('reviewed terminology and punctuation stay consistent', () { + final de = lookupAppLocalizations(const Locale('de')); + final es = lookupAppLocalizations(const Locale('es')); + final pt = lookupAppLocalizations(const Locale('pt', 'PT')); + expect(de.sensitivityNormal, 'Normal'); + expect(es.sensitivityNormal, 'Normal'); + expect(es.sensitivityPersonal, 'Personal'); + expect(pt.sensitivityNormal, 'Normal'); + + final estonian = File('lib/l10n/app_et.arb').readAsStringSync(); + expect(estonian, isNot(contains("Google'"))); + expect(estonian, contains('Lisage kõik kontod, mida soovite kasutada.')); + expect(estonian, contains('Ühendage Google’i')); + + expect( + File('lib/l10n/app_hi.arb').readAsStringSync(), + isNot(contains('ईवेंट')), + ); + for (final path in ['lib/l10n/app_zh.arb', 'lib/l10n/app_zh_Hans.arb']) { + final catalog = File(path).readAsStringSync(); + expect(catalog, isNot(contains('帐户')), reason: path); + expect(catalog, isNot(contains('活动')), reason: path); + } + expect( + File('lib/l10n/app_ar.arb').readAsStringSync(), + isNot(contains('ضيوف')), + ); + expect( + File('lib/l10n/app_fa.arb').readAsStringSync(), + isNot(contains('مهمانی')), + ); + expect( + File('lib/l10n/app_ko.arb').readAsStringSync(), + isNot(contains('게스트')), + ); + + final french = File('lib/l10n/app_fr.arb').readAsStringSync(); + expect(RegExp(r' [;:?!]').hasMatch(french), isFalse); + expect(french.contains('« '), isFalse); + expect(french.contains(' »'), isFalse); }); test('Hindi is generated and exposed as a supported locale', () { @@ -316,6 +465,732 @@ void main() { } }); + test( + 'RTL translations isolate dynamic values in confirmations and conflicts', + () { + const fsi = '\u2068'; + const pdi = '\u2069'; + final localizedValues = [ + AppLocalizationsAr(), + AppLocalizationsFa(), + ]; + + for (final l10n in localizedValues) { + expect( + l10n.removeAccountTitle('Acme (EU)'), + contains('${fsi}Acme (EU)$pdi'), + ); + expect( + l10n.deleteListConfirmation('Inbox / 2026'), + contains('${fsi}Inbox / 2026$pdi'), + ); + expect( + l10n.conflictNotificationBody('Meeting — 東京'), + contains('${fsi}Meeting — 東京$pdi'), + ); + } + }, + ); + + test('RTL catalogs isolate non-recurrence String placeholders', () { + const fsi = '\u2068'; + const pdi = '\u2069'; + final template = _decodeArb(File('lib/l10n/app_en.arb')); + + for (final locale in ['ar', 'fa']) { + final messages = _decodeArb(File('lib/l10n/app_$locale.arb')); + final failures = []; + for (final entry in template.entries) { + if (!entry.key.startsWith('@') || entry.key.startsWith('@@')) { + continue; + } + final messageKey = entry.key.substring(1); + if (messageKey.startsWith('repeat') || entry.value is! Map) { + continue; + } + final metadata = Map.from(entry.value as Map); + final placeholders = metadata['placeholders']; + if (placeholders is! Map) continue; + final translation = messages[messageKey] as String? ?? ''; + for (final placeholderEntry in placeholders.entries) { + final placeholderMetadata = placeholderEntry.value; + if (placeholderMetadata is! Map || + placeholderMetadata['type'] != 'String') { + continue; + } + final placeholder = placeholderEntry.key.toString(); + if (!translation.contains('$fsi{$placeholder}$pdi')) { + failures.add('$locale:$messageKey:$placeholder'); + } + } + } + expect(failures, isEmpty, reason: failures.join('\n')); + } + }); + + testWidgets('recurrence summaries use sentence-form localized grammar', ( + tester, + ) async { + const ordinalRule = RecurrenceRule( + frequency: RecurrenceFrequency.monthly, + interval: 1, + byDay: ['MO'], + byMonth: [], + byMonthDay: [], + bySetPosition: 1, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + const monthDaysRule = RecurrenceRule( + frequency: RecurrenceFrequency.monthly, + interval: 1, + byDay: [], + byMonth: [], + byMonthDay: [1, 15], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + const singleMonthDayRule = RecurrenceRule( + frequency: RecurrenceFrequency.monthly, + interval: 1, + byDay: [], + byMonth: [], + byMonthDay: [15], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + const yearlyMonthDayRule = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: [], + byMonth: [9], + byMonthDay: [15], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + const yearlyOrdinalRule = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: ['MO'], + byMonth: [9], + byMonthDay: [], + bySetPosition: 1, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + const yearlyMonthDaysRule = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: [], + byMonth: [9], + byMonthDay: [1, 15], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + const yearlyMonthsMonthDayRule = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: [], + byMonth: [9, 10], + byMonthDay: [15], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + const yearlyMonthsMonthDaysRule = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: [], + byMonth: [9, 10], + byMonthDay: [1, 15], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + const yearlyMonthsOrdinalRule = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: ['MO'], + byMonth: [9, 10], + byMonthDay: [], + bySetPosition: 1, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + const yearlyThreeMonthsAndDaysRule = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: [], + byMonth: [8, 9, 10], + byMonthDay: [1, 15, 20], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + const yearlyMonthsOnlyRule = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: [], + byMonth: [9, 10], + byMonthDay: [], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + + Future summary(Locale locale, RecurrenceRule rule) async { + var value = ''; + await tester.pumpWidget( + MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + value = recurrenceRuleSummary(context, rule); + return const SizedBox(); + }, + ), + ), + ); + return value; + } + + const expectedYearlySummaries = >{ + 'ar': [ + 'سنويًا في يوم 15 من سبتمبر', + 'سنويًا في يومي 1 و15 من سبتمبر', + 'سنويًا في اليوم 15 من شهري سبتمبر وأكتوبر', + 'سنويًا في يومي 1 و15 من شهري سبتمبر وأكتوبر', + 'سنويًا في أول اثنين من سبتمبر', + ], + 'de': [ + 'Jährlich am 15. Sept.', + 'Jährlich an den Tagen 1 und 15 im Sept.', + 'Jährlich jeweils am 15. im Sept. und Okt.', + 'Jährlich jeweils an den Tagen 1 und 15 im Sept. und Okt.', + 'Jährlich am ersten Montag im Sept.', + ], + 'en': [ + 'Yearly on Sep 15', + 'Yearly on days 1 and 15 of Sep', + 'Yearly on day 15 of Sep and Oct', + 'Yearly on days 1 and 15 of Sep and Oct', + 'Yearly on the first Monday of Sep', + ], + 'es': [ + 'Anual el día 15 de sept', + 'Anual los días 1 y 15 de sept', + 'Anual el día 15 de sept y oct', + 'Anual los días 1 y 15 de sept y oct', + 'Anual el primer lunes de sept', + ], + 'et': [ + 'Iga aasta septembris 15. päeval', + 'Iga aasta septembris 1. ja 15. päeval', + 'Iga aasta septembris ja oktoobris 15. päeval', + 'Iga aasta septembris ja oktoobris 1. ja 15. päeval', + 'Iga aasta septembris: esimene esmaspäev', + ], + 'fa': [ + 'سالانه در روز ۱۵ ماه سپتامبر', + 'سالانه در روزهای ۱ و ۱۵ ماه سپتامبر', + 'سالانه در روز ۱۵ ماه‌های سپتامبر و اکتبر', + 'سالانه در روزهای ۱ و ۱۵ ماه‌های سپتامبر و اکتبر', + 'سالانه در اولین دوشنبه ماه سپتامبر', + ], + 'fi': [ + 'Vuosittain syyskuun 15. päivänä', + 'Vuosittain syyskuun 1. ja 15. päivänä', + 'Vuosittain syyskuun ja lokakuun 15. päivänä', + 'Vuosittain syyskuun ja lokakuun 1. ja 15. päivänä', + 'Vuosittain syyskuun ensimmäisenä maanantaina', + ], + 'fr': [ + 'Annuel le 15 sept.', + 'Annuel les jours 1 et 15 de sept.', + 'Annuel le 15 de sept. et oct.', + 'Annuel les jours 1 et 15 de sept. et oct.', + 'Annuel le premier lundi de sept.', + ], + 'hi': [ + 'हर वर्ष सित॰ की 15 तारीख को', + 'हर वर्ष सित॰ की 1 और 15 तारीखों को', + 'हर वर्ष सित॰ और अक्तू॰ की 15 तारीख को', + 'हर वर्ष सित॰ और अक्तू॰ की 1 और 15 तारीखों को', + 'हर वर्ष सित॰ के पहले सोमवार को', + ], + 'it': [ + 'Ogni anno il giorno 15 di set', + 'Ogni anno nei giorni 1 e 15 di set', + 'Ogni anno il giorno 15 di set e ott', + 'Ogni anno nei giorni 1 e 15 di set e ott', + 'Ogni anno il primo lunedì di set', + ], + 'ja': [ + '毎年9月15日', + '毎年9月の1日と15日', + '毎年9月と10月の15日', + '毎年9月と10月の1日と15日', + '毎年9月の第1月曜日', + ], + 'ko': [ + '매년 9월 15일', + '매년 9월의 1일과 15일', + '매년 9월과 10월의 15일', + '매년 9월과 10월의 1일과 15일', + '매년 9월의 첫 번째 월요일', + ], + 'pt-PT': [ + 'Anualmente no dia 15 de set.', + 'Anualmente nos dias 1 e 15 de set.', + 'Anualmente no dia 15 de set. e out.', + 'Anualmente nos dias 1 e 15 de set. e out.', + 'Anualmente na primeira ocorrência de segunda-feira de set.', + ], + 'ru': [ + 'Ежегодно: сент., 15-го числа', + 'Ежегодно: сент., 1-го и 15-го числа', + 'Ежегодно: сент. и окт., 15-го числа', + 'Ежегодно: сент. и окт., 1-го и 15-го числа', + 'Ежегодно: сент., в первый понедельник', + ], + 'vi': [ + 'Hằng năm vào ngày 15 thg 9', + 'Hằng năm vào các ngày 1 và 15 của thg 9', + 'Hằng năm vào ngày 15 của thg 9 và thg 10', + 'Hằng năm vào các ngày 1 và 15 của thg 9 và thg 10', + 'Hằng năm vào Thứ Hai đầu tiên của thg 9', + ], + 'zh': [ + '每年9月15日', + '每年9月的1日和15日', + '每年9月和10月的15日', + '每年9月和10月的1日和15日', + '每年9月的第一个星期一', + ], + 'zh-Hans': [ + '每年9月15日', + '每年9月的1日和15日', + '每年9月和10月的15日', + '每年9月和10月的1日和15日', + '每年9月的第一个星期一', + ], + 'zh-Hant': [ + '每年9月15日', + '每年9月的1日和15日', + '每年9月和10月的15日', + '每年9月和10月的1日和15日', + '每年9月的第一個星期一', + ], + }; + + for (final locale in busyMaxSupportedLocales) { + final values = [ + await summary(locale, yearlyMonthDayRule), + await summary(locale, yearlyMonthDaysRule), + await summary(locale, yearlyMonthsMonthDayRule), + await summary(locale, yearlyMonthsMonthDaysRule), + await summary(locale, yearlyOrdinalRule), + ]; + expect( + values, + expectedYearlySummaries[locale.toLanguageTag()], + reason: locale.toLanguageTag(), + ); + } + expect( + await summary(const Locale('en'), yearlyMonthsOrdinalRule), + lookupAppLocalizations(const Locale('en')).unsupportedRecurrencePreserved, + ); + expect( + await summary(const Locale('en'), yearlyThreeMonthsAndDaysRule), + 'Yearly on days 1, 15 and 20 of Aug, Sep and Oct', + ); + expect( + await summary(const Locale('ja'), yearlyThreeMonthsAndDaysRule), + '毎年8月、9月と10月の1日、15日と20日', + ); + expect( + await summary(const Locale('en'), yearlyMonthsOnlyRule), + 'Yearly in Sep and Oct', + ); + expect( + await summary(const Locale('et'), yearlyMonthsOnlyRule), + 'Iga aasta septembris ja oktoobris', + ); + expect( + await summary(const Locale('fi'), yearlyMonthsOnlyRule), + 'Vuosittain syyskuun ja lokakuun aikana', + ); + + expect( + await summary(const Locale('de'), ordinalRule), + 'Monatlich am ersten Montag', + ); + expect(await summary(const Locale('ja'), ordinalRule), '毎月第1月曜日'); + expect(await summary(const Locale('ja'), singleMonthDayRule), '毎月15日'); + expect(await summary(const Locale('ja'), monthDaysRule), '毎月1日、15日'); + expect(await summary(const Locale('ko'), singleMonthDayRule), '매월 15일'); + expect(await summary(const Locale('ko'), monthDaysRule), '매월 1일과 15일'); + expect( + await summary(const Locale('et'), ordinalRule), + 'Iga kuu esimene esmaspäev', + ); + expect( + await summary(const Locale('ru'), monthDaysRule), + 'Ежемесячно 1-го и 15-го числа каждого месяца', + ); + expect( + await summary(const Locale('et'), singleMonthDayRule), + 'Iga kuu 15. päeval', + ); + expect( + await summary(const Locale('et'), monthDaysRule), + 'Iga kuu 1. ja 15. päeval', + ); + expect( + await summary(const Locale('hi'), singleMonthDayRule), + 'हर महीने की 15 तारीख को', + ); + expect( + await summary(const Locale('hi'), monthDaysRule), + 'हर महीने की 1 और 15 तारीखों को', + ); + expect( + await summary(const Locale('ar'), monthDaysRule), + 'شهريًا في يومي 1 و15 من الشهر', + ); + expect( + await summary(const Locale('hi'), ordinalRule), + 'हर महीने के पहले सोमवार को', + ); + for (final locale in [ + const Locale('ja'), + const Locale('ko'), + const Locale('zh'), + const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'), + const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), + ]) { + final monthDays = await summary(locale, yearlyMonthDayRule); + final ordinal = await summary(locale, yearlyOrdinalRule); + if (locale.languageCode == 'ja') { + expect(monthDays, '毎年9月15日'); + expect(ordinal, '毎年9月の第1月曜日'); + } else if (locale.languageCode == 'ko') { + expect(monthDays, '매년 9월 15일'); + expect(ordinal, '매년 9월의 첫 번째 월요일'); + } else if (locale.scriptCode == 'Hant') { + expect(monthDays, '每年9月15日'); + expect(ordinal, '每年9月的第一個星期一'); + } else { + expect(monthDays, '每年9月15日'); + expect(ordinal, '每年9月的第一个星期一'); + } + } + final persianMonthDay = await summary( + const Locale('fa'), + singleMonthDayRule, + ); + expect(persianMonthDay, contains('۱۵')); + expect(persianMonthDay, isNot(contains('15'))); + }); + + testWidgets( + 'recurrence plurals, ordinal labels, and lists follow locale grammar', + (tester) async { + RecurrenceRule rule({ + RecurrenceFrequency frequency = RecurrenceFrequency.daily, + int interval = 1, + List byDay = const [], + List byMonth = const [], + List byMonthDay = const [], + int? bySetPosition, + int? count, + }) => RecurrenceRule( + frequency: frequency, + interval: interval, + byDay: byDay, + byMonth: byMonth, + byMonthDay: byMonthDay, + bySetPosition: bySetPosition, + count: count, + untilRaw: null, + recurrenceDates: const [], + exceptionDates: const [], + rawRules: const [], + isSupported: true, + ); + + Future summary(Locale locale, RecurrenceRule recurrence) async { + var value = ''; + await tester.pumpWidget( + MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + value = recurrenceRuleSummary(context, recurrence); + return const SizedBox(); + }, + ), + ), + ); + return value; + } + + const singularCountSummaries = { + 'en': 'Daily · 1 time', + 'es': 'Diaria · 1 vez', + 'et': 'Iga päev · 1 kord', + 'fi': 'Päivittäin · 1 kerran', + 'it': 'Ogni giorno · 1 volta', + 'pt-PT': 'Diariamente · 1 vez', + 'ru': 'Ежедневно · 1 раз', + 'ar': 'يوميًا · مرة واحدة', + }; + for (final entry in singularCountSummaries.entries) { + expect( + await summary(busyMaxLocaleFromTag(entry.key)!, rule(count: 1)), + entry.value, + reason: entry.key, + ); + } + expect( + await summary(const Locale('ru'), rule(count: 2)), + 'Ежедневно · 2 раза', + ); + expect( + await summary(const Locale('ar'), rule(count: 2)), + 'يوميًا · مرتين', + ); + expect( + await summary(const Locale('ar'), rule(count: 3)), + 'يوميًا · 3 مرات', + ); + expect( + await summary(const Locale('ar'), rule(count: 11)), + 'يوميًا · 11 مرة', + ); + + const arabicIntervals = >{ + RecurrenceFrequency.daily: ['كل يومين', 'كل 3 أيام', 'كل 11 يومًا'], + RecurrenceFrequency.weekly: [ + 'كل أسبوعين', + 'كل 3 أسابيع', + 'كل 11 أسبوعًا', + ], + RecurrenceFrequency.monthly: ['كل شهرين', 'كل 3 أشهر', 'كل 11 شهرًا'], + RecurrenceFrequency.yearly: ['كل سنتين', 'كل 3 سنوات', 'كل 11 سنة'], + }; + for (final entry in arabicIntervals.entries) { + for (var index = 0; index < 3; index += 1) { + final interval = const [2, 3, 11][index]; + expect( + await summary( + const Locale('ar'), + rule(frequency: entry.key, interval: interval), + ), + entry.value[index], + reason: '${entry.key.name}: $interval', + ); + } + } + + const allDays = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']; + const weekdays = ['MO', 'TU', 'WE', 'TH', 'FR']; + const weekend = ['SA', 'SU']; + final ordinalChoices = , List>{ + allDays: ['شهريًا في أول يوم', 'Kuukausittain ensimmäisenä päivänä'], + weekdays: [ + 'شهريًا في أول يوم من أيام الأسبوع', + 'Kuukausittain ensimmäisenä arkipäivänä', + ], + weekend: [ + 'شهريًا في أول يوم من عطلة نهاية الأسبوع', + 'Kuukausittain ensimmäisenä viikonlopun päivänä', + ], + }; + for (final entry in ordinalChoices.entries) { + final recurrence = rule( + frequency: RecurrenceFrequency.monthly, + byDay: entry.key, + bySetPosition: 1, + ); + expect(await summary(const Locale('ar'), recurrence), entry.value[0]); + expect(await summary(const Locale('fi'), recurrence), entry.value[1]); + } + expect( + await summary( + const Locale('ar'), + rule( + frequency: RecurrenceFrequency.monthly, + byDay: const ['MO'], + bySetPosition: 1, + ), + ), + 'شهريًا في أول اثنين', + ); + expect( + await summary( + const Locale('ar'), + rule( + frequency: RecurrenceFrequency.yearly, + byDay: allDays, + byMonth: const [9], + bySetPosition: 1, + ), + ), + 'سنويًا في أول يوم من سبتمبر', + ); + + final weekly = rule( + frequency: RecurrenceFrequency.weekly, + byDay: const ['MO', 'WE'], + ); + expect(await summary(const Locale('ja'), weekly), '毎週月曜日、水曜日'); + expect(await summary(const Locale('zh'), weekly), '每周在 星期一和星期三'); + expect( + await summary(const Locale('ar'), weekly), + 'أسبوعيًا في الاثنين والأربعاء', + ); + expect( + await summary(const Locale('de'), weekly), + 'Wöchentlich montags und mittwochs', + ); + expect( + await summary(const Locale('et'), weekly), + 'Iga nädal esmaspäeviti ja kolmapäeviti', + ); + expect( + await summary(const Locale('fi'), weekly), + 'Viikoittain maanantaisin ja keskiviikkoisin', + ); + expect( + await summary(const Locale('fr'), weekly), + 'Hebdomadaire le lundi et le mercredi', + ); + expect( + await summary(const Locale('it'), weekly), + 'Ogni settimana il lunedì e il mercoledì', + ); + expect( + await summary(const Locale('pt', 'PT'), weekly), + 'Semanalmente à segunda-feira e à quarta-feira', + ); + expect( + await summary(const Locale('ru'), weekly), + 'Еженедельно по понедельникам и средам', + ); + + final monthly = rule( + frequency: RecurrenceFrequency.monthly, + byMonthDay: const [1, 15], + ); + final monthlyThreeDates = rule( + frequency: RecurrenceFrequency.monthly, + byMonthDay: const [1, 15, 20], + ); + expect( + await summary(const Locale('en'), monthly), + 'Monthly on days 1 and 15', + ); + expect(await summary(const Locale('ja'), monthly), '毎月1日、15日'); + expect(await summary(const Locale('zh'), monthly), '每月1日、15日'); + expect( + await summary(const Locale('hi'), monthly), + 'हर महीने की 1 और 15 तारीखों को', + ); + expect( + await summary(const Locale('ar'), monthly), + 'شهريًا في يومي 1 و15 من الشهر', + ); + expect( + await summary(const Locale('en'), monthlyThreeDates), + 'Monthly on days 1, 15 and 20', + ); + expect( + await summary(const Locale('hi'), monthlyThreeDates), + 'हर महीने की 1, 15 और 20 तारीखों को', + ); + expect( + await summary(const Locale('ar'), monthlyThreeDates), + 'شهريًا في الأيام 1، 15 و20 من الشهر', + ); + }, + ); + + test('recurrence summaries do not use hard-coded list separators', () { + final source = File( + 'lib/src/features/recurrence/presentation/recurrence_editor.dart', + ).readAsStringSync(); + + expect(source, isNot(contains(".join(', ')"))); + expect(source, isNot(contains('repeatMonthDayListSeparator'))); + expect(source, contains('repeatWeekdayListPair')); + expect(source, contains('repeatMonthDayListPair')); + expect(source, contains('repeatWeeklyDaySummary')); + expect( + source, + isNot(contains('l10n.repeatYearlyInMonthsOnOrdinalSummary')), + ); + expect( + source, + contains( + '!widget.limits.allowMultipleMonths || _value.bySetPosition != null', + ), + ); + }); + test('Persian dynamic numbers use Persian digits', () { const fsi = '\u2068'; const pdi = '\u2069'; @@ -329,6 +1204,142 @@ void main() { expect(AppLocalizationsAr().moreItems(12), contains('${fsi}12$pdi')); }); + test('all human-facing Persian numeric placeholders use Persian digits', () { + final fa = AppLocalizationsFa(); + final valuesWithTwelve = [ + fa.calendarColorOption(12), + fa.moreItems(12), + fa.trayTasksDueToday(12), + fa.trayLastSyncedMinutesAgo(12), + fa.trayLastSyncedHoursAgo(12), + fa.trayLastSyncedDaysAgo(12), + fa.reminderMinutesBefore(12), + fa.reminderHoursBefore(12), + fa.reminderDaysBefore(12), + fa.completionPercent(12), + fa.relatedRemindersDescription(12), + fa.repeatEveryDays(12), + fa.repeatEveryWeeks(12), + fa.repeatEveryMonths(12), + fa.repeatEveryYears(12), + fa.repeatTimesSummary(12), + fa.pendingOpAttempts(12), + fa.dueTodayNotificationBody(12), + fa.weekNumberTooltip(12), + fa.scheduleItemCount(12), + fa.importEventsFound(12), + fa.importInvalidEvents(12), + fa.importQueued(12), + fa.importDuplicatesSkipped(12), + fa.importUnsupportedSets(12), + ]; + for (final value in valuesWithTwelve) { + expect(value, contains('۱۲'), reason: value); + expect(value, isNot(contains('12')), reason: value); + } + for (final value in [ + fa.priorityHighValue(4), + fa.priorityMediumValue(4), + fa.priorityLowValue(4), + ]) { + expect(value, contains('۴'), reason: value); + expect(value, isNot(contains('4')), reason: value); + } + }); + + test( + 'Finnish and Arabic quantities select singular and Arabic dual forms', + () { + final fi = lookupAppLocalizations(const Locale('fi')); + final ar = AppLocalizationsAr(); + const fsi = '\u2068'; + const pdi = '\u2069'; + + expect(fi.moreItems(1), '+1 muu'); + expect(fi.moreItems(2), '+2 muuta'); + expect(ar.moreItems(1), '+عنصر واحد آخر'); + expect(ar.moreItems(2), '+عنصران آخران'); + expect(ar.moreItems(3), '+${fsi}3$pdi عناصر أخرى'); + expect(ar.moreItems(11), '+${fsi}11$pdi عنصرًا آخر'); + + expect(ar.trayLastSyncedMinutesAgo(2), 'تمت المزامنة قبل دقيقتين'); + expect(ar.trayLastSyncedHoursAgo(2), 'تمت المزامنة قبل ساعتين'); + expect(ar.trayLastSyncedDaysAgo(2), 'تمت المزامنة قبل يومين'); + expect( + ar.trayLastSyncedMinutesAgo(3), + 'تمت المزامنة قبل ${fsi}3$pdi دقائق', + ); + expect( + ar.trayLastSyncedMinutesAgo(11), + 'تمت المزامنة قبل ${fsi}11$pdi دقيقة', + ); + }, + ); + + test('yearly recurrence month forms cover every month', () { + const monthKeys = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec', + ]; + const estonian = [ + 'jaanuaris', + 'veebruaris', + 'märtsis', + 'aprillis', + 'mais', + 'juunis', + 'juulis', + 'augustis', + 'septembris', + 'oktoobris', + 'novembris', + 'detsembris', + ]; + const finnish = [ + 'tammikuun', + 'helmikuun', + 'maaliskuun', + 'huhtikuun', + 'toukokuun', + 'kesäkuun', + 'heinäkuun', + 'elokuun', + 'syyskuun', + 'lokakuun', + 'marraskuun', + 'joulukuun', + ]; + final et = lookupAppLocalizations(const Locale('et')); + final fi = lookupAppLocalizations(const Locale('fi')); + + for (var index = 0; index < monthKeys.length; index += 1) { + expect( + et.repeatYearlyMonthValue('fallback', monthKeys[index]), + estonian[index], + ); + expect( + fi.repeatYearlyMonthValue('fallback', monthKeys[index]), + finnish[index], + ); + } + expect( + lookupAppLocalizations( + const Locale('en'), + ).repeatYearlyMonthValue('Sep', 'sep'), + 'Sep', + ); + }); + test('both Chinese scripts are generated and supported', () { const simplified = Locale.fromSubtags( languageCode: 'zh', @@ -392,7 +1403,7 @@ void main() { }); test('every selectable locale has a generated catalog', () { - final generated = AppLocalizations.supportedLocales.toSet() + final generated = busyMaxSupportedLocales.toSet() ..remove(const Locale('zh')); final selectable = busyMaxLocaleOptions .map((option) => option.locale) @@ -422,7 +1433,8 @@ Iterable _translatedArbFiles() sync* { for (final entity in Directory('lib/l10n').listSync()) { if (entity is File && entity.path.endsWith('.arb') && - !entity.path.endsWith('app_en.arb')) { + !entity.path.endsWith('app_en.arb') && + !entity.path.endsWith('app_pt.arb')) { yield entity; } } @@ -512,6 +1524,85 @@ Iterable _declaredPlaceholders( yield* placeholders.keys.cast(); } +Iterable _usedPlaceholders(String value) sync* { + yield* _scanIcuPlaceholders(value); +} + +Iterable _scanIcuPlaceholders(String value) sync* { + for (var index = 0; index < value.length;) { + if (value[index] != '{') { + index += 1; + continue; + } + + final closing = _matchingBrace(value, index); + if (closing == -1) { + index += 1; + continue; + } + + final body = value.substring(index + 1, closing); + final nameMatch = RegExp(r'^\s*([A-Za-z][A-Za-z0-9_]*)').firstMatch(body); + if (nameMatch != null) { + final name = nameMatch.group(1)!; + final remainder = body.substring(nameMatch.end).trimLeft(); + if (remainder.isEmpty || remainder.startsWith(',')) { + yield name; + } + + final selectMatch = RegExp( + r'^\s*[A-Za-z][A-Za-z0-9_]*\s*,\s*select\s*,', + ).firstMatch(body); + if (selectMatch != null) { + yield* _scanIcuSelectOptions(body.substring(selectMatch.end)); + } else if (remainder.startsWith(',')) { + yield* _scanIcuPlaceholders(body.substring(nameMatch.end)); + } + } else { + yield* _scanIcuPlaceholders(body); + } + + index = closing + 1; + } +} + +Iterable _scanIcuSelectOptions(String value) sync* { + for (var index = 0; index < value.length;) { + while (index < value.length && value[index].trim().isEmpty) { + index += 1; + } + if (index >= value.length) return; + + final optionMatch = RegExp( + r'[A-Za-z][A-Za-z0-9_]*', + ).matchAsPrefix(value, index); + if (optionMatch == null) return; + index = optionMatch.end; + while (index < value.length && value[index].trim().isEmpty) { + index += 1; + } + if (index >= value.length || value[index] != '{') return; + + final closing = _matchingBrace(value, index); + if (closing == -1) return; + yield* _scanIcuPlaceholders(value.substring(index + 1, closing)); + index = closing + 1; + } +} + +int _matchingBrace(String value, int opening) { + var depth = 0; + for (var index = opening; index < value.length; index += 1) { + if (value[index] == '{') { + depth += 1; + } else if (value[index] == '}') { + depth -= 1; + if (depth == 0) return index; + } + } + return -1; +} + int _lineForOffset(String source, int offset) { return '\n'.allMatches(source.substring(0, offset)).length + 1; } diff --git a/test/app/localized_formatters_test.dart b/test/app/localized_formatters_test.dart index 5d161f51..6b08d857 100644 --- a/test/app/localized_formatters_test.dart +++ b/test/app/localized_formatters_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:busymax/src/l10n/localized_formatters.dart'; import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -18,7 +20,7 @@ void main() { 'it': 'Luglio', 'ja': '7月', 'ko': '7월', - 'pt': 'julho', + 'pt_PT': 'julho', 'ru': 'Июль', 'vi': 'Tháng 7', 'zh': '七月', @@ -34,6 +36,193 @@ void main() { } }); + test( + 'full day headings use heading context without global capitalization', + () async { + const expected = { + 'ar': 'الاثنين، 7 سبتمبر 2026', + 'de': 'Montag, 7. September 2026', + 'en': 'Monday, September 7, 2026', + 'es': 'Lunes, 7 de septiembre de 2026', + 'et': 'esmaspäev, 7. september 2026', + 'fa': 'دوشنبه ۷ سپتامبر ۲۰۲۶', + 'fi': 'maanantai 7. syyskuuta 2026', + 'fr': 'Lundi 7 septembre 2026', + 'hi': 'सोमवार, 7 सितंबर 2026', + 'it': 'Lunedì 7 settembre 2026', + 'ja': '2026年9月7日月曜日', + 'ko': '2026년 9월 7일 월요일', + 'pt_PT': 'Segunda-feira, 7 de setembro de 2026', + 'ru': 'Понедельник, 7 сентября 2026 г.', + 'vi': 'Thứ Hai, 7 tháng 9, 2026', + 'zh': '2026年9月7日星期一', + 'zh_Hans': '2026年9月7日星期一', + 'zh_Hant': '2026年9月7日星期一', + }; + + for (final entry in expected.entries) { + await initializeDateFormatting(entry.key); + expect( + localizedDayHeading(entry.key, DateTime(2026, 9, 7)), + entry.value, + reason: entry.key, + ); + } + }, + ); + + test('month-year headings use standalone month context', () async { + const expected = { + 'ar': 'سبتمبر 2026', + 'de': 'September 2026', + 'en': 'September 2026', + 'es': 'Septiembre de 2026', + 'et': 'september 2026', + 'fa': 'سپتامبر ۲۰۲۶', + 'fi': 'syyskuu 2026', + 'fr': 'Septembre 2026', + 'hi': 'सितंबर 2026', + 'it': 'Settembre 2026', + 'ja': '2026年9月', + 'ko': '2026년 9월', + 'pt_PT': 'setembro de 2026', + 'ru': 'Сентябрь 2026 г.', + 'vi': 'tháng 9 năm 2026', + 'zh': '2026年9月', + 'zh_Hans': '2026年9月', + 'zh_Hant': '2026年9月', + }; + + for (final entry in expected.entries) { + await initializeDateFormatting(entry.key); + expect( + localizedMonthYearHeading(entry.key, DateTime(2026, 9)), + entry.value, + reason: entry.key, + ); + } + }); + + test( + 'standalone weekday and month labels use their own grammatical forms', + () async { + const expectedWeekdays = { + 'ar': ('الاثنين', 'الاثنين'), + 'de': ('Montag', 'Mo'), + 'en': ('Monday', 'Mon'), + 'es': ('Lunes', 'Lun'), + 'et': ('esmaspäev', 'E'), + 'fa': ('دوشنبه', 'دوشنبه'), + 'fi': ('maanantai', 'ma'), + 'fr': ('Lundi', 'Lun.'), + 'hi': ('सोमवार', 'सोम'), + 'it': ('Lunedì', 'Lun'), + 'ja': ('月曜日', '月'), + 'ko': ('월요일', '월'), + 'pt_PT': ('Segunda-feira', 'Segunda'), + 'ru': ('Понедельник', 'Пн'), + 'vi': ('Thứ Hai', 'Th 2'), + 'zh': ('星期一', '周一'), + 'zh_Hans': ('星期一', '周一'), + 'zh_Hant': ('星期一', '周一'), + }; + const expectedMonths = { + 'ar': ('سبتمبر', 'سبتمبر'), + 'de': ('September', 'Sep'), + 'en': ('September', 'Sep'), + 'es': ('Septiembre', 'Sept'), + 'et': ('september', 'september'), + 'fa': ('سپتامبر', 'سپتامبر'), + 'fi': ('syyskuu', 'syys'), + 'fr': ('Septembre', 'Sept.'), + 'hi': ('सितंबर', 'सित॰'), + 'it': ('Settembre', 'Set'), + 'ja': ('9月', '9月'), + 'ko': ('9월', '9월'), + 'pt_PT': ('setembro', 'set.'), + 'ru': ('Сентябрь', 'Сент.'), + 'vi': ('Tháng 9', 'Tháng 9'), + 'zh': ('九月', '9月'), + 'zh_Hans': ('九月', '9月'), + 'zh_Hant': ('九月', '9月'), + }; + + for (final locale in expectedWeekdays.keys) { + await initializeDateFormatting(locale); + final weekday = expectedWeekdays[locale]!; + final month = expectedMonths[locale]!; + expect( + localizedWeekdayLabel(locale, DateTime(2026, 9, 7)), + weekday.$1, + reason: '$locale full weekday', + ); + expect( + localizedWeekdayLabel( + locale, + DateTime(2026, 9, 7), + abbreviated: true, + ), + weekday.$2, + reason: '$locale abbreviated weekday', + ); + expect( + localizedMonthLabel(locale, DateTime(2026, 9)), + month.$1, + reason: '$locale full month', + ); + expect( + localizedMonthLabel(locale, DateTime(2026, 9), abbreviated: true), + month.$2, + reason: '$locale abbreviated month', + ); + } + }, + ); + + test( + 'inline date names stay lowercase where locale data requires it', + () async { + for (final locale in ['es', 'fr', 'it', 'pt', 'ru', 'fi', 'et']) { + await initializeDateFormatting(locale); + final date = DateTime(2026, 9, 7); + expect( + localizedInlineWeekday(locale, date), + isNot(contains(RegExp(r'^[A-ZÁÉÍÓÚÀÂÄÇÈÉÊËÎÏÔÖÙÛÜŸ]'))), + reason: '$locale inline weekday', + ); + expect( + localizedInlineMonth(locale, date), + isNot(contains(RegExp(r'^[A-ZÁÉÍÓÚÀÂÄÇÈÉÊËÎÏÔÖÙÛÜŸ]'))), + reason: '$locale inline month', + ); + } + }, + ); + + test('calendar heading widgets do not call DateFormat directly', () { + const files = [ + 'lib/src/features/schedule/presentation/schedule_toolbar.dart', + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', + 'lib/src/features/schedule/presentation/schedule_more_popover.dart', + 'lib/src/features/schedule/presentation/calendar_day_semantics.dart', + 'lib/src/features/schedule/presentation/mini_calendar.dart', + 'lib/src/features/schedule/presentation/schedule_month_view.dart', + 'lib/src/features/schedule/presentation/schedule_day_week_view.dart', + 'lib/src/features/tasks/presentation/desktop_date_time_fields.dart', + ]; + final forbidden = RegExp(r'DateFormat\.(?:yMMMMEEEEd|yMMMM|E|MMMM)\b'); + + for (final path in files) { + final source = File(path).readAsStringSync(); + expect( + source, + isNot(contains(forbidden)), + reason: '$path must use localized_formatters.dart', + ); + } + }); + test('schedule ranges localize each complete endpoint', () async { await initializeDateFormatting('de'); final range = ScheduleRange( diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 3e32ecd1..b388e60f 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -241,7 +241,8 @@ void main() { expect(router, isNot(contains("path: '/diagnostics'"))); expect(router, isNot(contains('DiagnosticsScreen'))); expect(settings, contains('SettingsPage.system')); - expect(settings, contains('l10n.manualFullSync')); + expect(settings, contains('l10n.forceFullResync')); + expect(settings, contains('l10n.forceFullResyncDescription')); expect(settings, contains('l10n.currentLocale')); expect(settings, isNot(contains('SettingsPage.sync'))); expect(settings, isNot(contains('SettingsPage.appearance'))); @@ -290,24 +291,23 @@ void main() { }, ); - test('tray DBus menu labels come from injected labels', () { + test('tray DBus menu labels come from its injected presentation', () { final source = File( 'lib/src/platform/busymax_tray_service.dart', ).readAsStringSync(); final logo = File('assets/branding/busymax-logo.svg').readAsStringSync(); - expect(source, contains('class BusyMaxTrayLabels')); + expect(source, contains('BusyMaxTrayMenuPresentation presentation')); expect(source, contains('buildBusyMaxTrayMenu')); + expect(source, contains('label: presentation.showBusyMaxLabel')); + expect(source, contains('label: presentation.todayLabel')); + expect(source, contains('static const orderedChildren')); expect(source, contains('busyMaxApplicationId')); expect(source, contains('io.busystack.busymax')); expect(source, contains('assets/branding/busymax-logo.svg')); expect(logo, contains('width="512" height="512"')); expect(logo, contains('viewBox="106 108 300 300"')); expect(logo, isNot(contains('viewBox="254 120 232 272"'))); - expect(source, isNot(contains('BusyMaxTrayAgendaSnapshot'))); - expect(source, isNot(contains('BusyMaxTrayAgendaEntry'))); - expect(source, isNot(contains('_buildAgendaSubmenuItems'))); - expect(source, isNot(contains('busyMaxTrayAgendaSlotCount'))); expect(source, isNot(contains("iconName: 'busymax-symbolic'"))); expect(source, isNot(contains("label: 'Show BusyMax'"))); expect(source, isNot(contains("label: 'Today'"))); @@ -327,7 +327,7 @@ void main() { }); test('startup defers native presentation until the app frame is ready', () { - final source = File('lib/main.dart').readAsStringSync(); + final source = File('lib/main_linux.dart').readAsStringSync(); final deferFrame = source.indexOf('binding.deferFirstFrame();'); final firstRunApp = source.indexOf('runApp('); final firstAllowFrame = source.indexOf('binding.allowFirstFrame();'); @@ -340,9 +340,7 @@ void main() { test('snap uses portal-backed secret storage without keyring plug', () { final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); - final bootstrap = File( - 'lib/src/app/app_bootstrap.dart', - ).readAsStringSync(); + final linuxMain = File('lib/main_linux.dart').readAsStringSync(); final portalStore = File( 'lib/src/core/secrets/portal_encrypted_secret_store.dart', ).readAsStringSync(); @@ -352,7 +350,7 @@ void main() { expect(snapcraft, contains('GDK_BACKEND: wayland,x11')); expect(snapcraft, contains('SECRET_BACKEND: file')); expect(snapcraft, isNot(contains('password-manager-service'))); - expect(bootstrap, contains('PortalEncryptedSecretStore')); + expect(linuxMain, contains('PortalEncryptedSecretStore')); expect(portalStore, contains('org.freedesktop.portal.Secret')); expect(portalStore, contains('RetrieveSecret')); expect(portalStore, contains('AesGcm.with256bits')); @@ -383,7 +381,12 @@ void main() { expect(app, contains("ref.read(appRouterProvider).go('/schedule')")); expect(commands, contains('agenda,')); expect(workspace, contains('case ScheduleWorkspaceCommandKind.agenda:')); - expect(workspace, contains('_setMode(ScheduleViewMode.agenda);')); + expect( + workspace, + contains( + '_setMode(ScheduleViewMode.agenda, agendaDate: command.date);', + ), + ); }); test('native headerbar keeps sidebar branded with GTK-owned centering', () { @@ -2182,7 +2185,7 @@ void main() { 'lib/src/platform/gtk_font_service.dart', ).readAsStringSync(); final app = File('lib/src/app/busymax_app.dart').readAsStringSync(); - final main = File('lib/main.dart').readAsStringSync(); + final main = File('lib/main_linux.dart').readAsStringSync(); expect(source, contains('kGtkThemeColorsEventChannel')); expect(source, contains('io.busystack.busymax/gtk_theme_colors')); @@ -2313,11 +2316,13 @@ void main() { final matches = []; for (final file in _dartFilesIn('lib')) { final lines = file.readAsLinesSync(); + final path = _normalizedPath(file); for (var index = 0; index < lines.length; index++) { final line = lines[index]; - final location = '${file.path}:${index + 1}'; + final location = '$path:${index + 1}'; if (line.contains('fontFamily:') && - !file.path.endsWith('lib/src/app/busymax_yaru_theme.dart')) { + !path.endsWith('lib/src/app/busymax_yaru_theme.dart') && + !path.endsWith('lib/src/app/windows/windows_busymax_app.dart')) { matches.add('$location: $line'); } if (line.contains('fontSize:') && @@ -2471,11 +2476,12 @@ void main() { for (final file in files) { final lines = file.readAsLinesSync(); + final path = _normalizedPath(file); for (var index = 0; index < lines.length; index++) { final line = lines[index]; - final location = '${file.path}:${index + 1}'; + final location = '$path:${index + 1}'; final isSharedConfirmationFallback = - file.path.endsWith('lib/src/app/busymax_design.dart') && + path.endsWith('lib/src/app/busymax_design.dart') && line.contains('return AlertDialog('); expect( line.contains('AlertDialog'), @@ -2560,7 +2566,7 @@ bool _hasRawMenuItemButton(String line) { } bool _hasRawPopupMenuEntry(File file, String line) { - if (file.path.endsWith('lib/src/app/busymax_design.dart')) { + if (_normalizedPath(file).endsWith('lib/src/app/busymax_design.dart')) { return false; } return line.contains('PopupMenuItem') || @@ -2568,7 +2574,7 @@ bool _hasRawPopupMenuEntry(File file, String line) { } bool _hasRawMenuAnchor(File file, String line) { - if (file.path.endsWith('lib/src/app/busymax_design.dart')) { + if (_normalizedPath(file).endsWith('lib/src/app/busymax_design.dart')) { return false; } return RegExp(r'\bMenuAnchor\s*\(').hasMatch(line); @@ -2583,7 +2589,7 @@ bool _hasRawSwitch(String line) { } bool _hasRawIconButton(File file, String line) { - if (file.path.endsWith('lib/src/app/busymax_design.dart')) { + if (_normalizedPath(file).endsWith('lib/src/app/busymax_design.dart')) { return false; } return line.contains('IconButton(') && @@ -2593,15 +2599,18 @@ bool _hasRawIconButton(File file, String line) { } bool _isAllowedFontSizeException(File file, String line) { - if (file.path.endsWith('lib/src/app/busymax_yaru_theme.dart')) { + final path = _normalizedPath(file); + if (path.endsWith('lib/src/app/busymax_yaru_theme.dart')) { return true; } - if (file.path.endsWith('lib/src/app/busymax_app.dart') && + if (path.endsWith('lib/src/app/busymax_app.dart') && line.contains('theme.tooltipTheme.textStyle?.fontSize')) { return true; } - return file.path.endsWith( + return path.endsWith( 'lib/src/features/tasks/presentation/desktop_date_time_fields.dart', ) && line.contains('fontSize: 0'); } + +String _normalizedPath(File file) => file.path.replaceAll('\\', '/'); diff --git a/test/app/rtl_glyphs_test.dart b/test/app/rtl_glyphs_test.dart index 229c5050..1db83746 100644 --- a/test/app/rtl_glyphs_test.dart +++ b/test/app/rtl_glyphs_test.dart @@ -38,6 +38,21 @@ void main() { ); }); + test('calendar option glyphs resolve for native GTK menus', () { + expect( + BusyMaxGlyphs.nativeMenuIconName(Icons.palette_outlined), + 'color-select-symbolic', + ); + expect( + BusyMaxGlyphs.nativeMenuIconName(Icons.notifications_outlined), + 'preferences-system-notifications-symbolic', + ); + expect( + BusyMaxGlyphs.nativeMenuIconName(Icons.notifications_off_outlined), + 'notifications-disabled-symbolic', + ); + }); + test('Arabic and Persian glyph coverage is packaged in the snap', () { expect( File('snap/snapcraft.yaml').readAsStringSync(), diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index b9f3c6c9..5a9f84ce 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -11,6 +11,7 @@ import 'package:yaru/theme.dart'; import 'package:yaru/widgets.dart' show YaruInfoType; import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/app/app_router.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:busymax/src/app/app_theme.dart'; import 'package:busymax/src/app/busymax_app.dart'; @@ -18,6 +19,8 @@ import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; +import 'package:busymax/src/features/sync/all_accounts_sync_scheduler.dart'; +import 'package:busymax/src/features/tray/domain/tray_presentation.dart'; import 'package:busymax/src/l10n/l10n.dart'; import 'package:busymax/src/platform/busymax_tray_service.dart'; import 'package:busymax/src/platform/gtk_font_service.dart'; @@ -1860,7 +1863,7 @@ void main() { addTearDown(database.close); final store = _DelayedSettingsStore(); final windowService = _RecordingWindowService(); - final trayService = _RecordingTrayService(windowService); + _RecordingTrayService? trayService; await tester.pumpWidget( ProviderScope( @@ -1871,18 +1874,14 @@ void main() { linuxWindowServiceProvider.overrideWithValue(windowService), ], child: BusyMaxApp( - trayServiceFactory: - ({ - required windowService, - required labels, - required onOpenAgenda, - }) => trayService, + trayServiceFactory: (configuration) => + trayService = _RecordingTrayService(configuration), ), ), ); await tester.pump(); - expect(trayService.startCalls, 0); + expect(trayService?.startCalls ?? 0, 0); expect(windowService.hideWindowCalls, 0); store.completeLoad({ @@ -1893,13 +1892,13 @@ void main() { await tester.pump(); await tester.pump(); - expect(trayService.startCalls, 1); + expect(trayService!.startCalls, 1); expect(windowService.hideWindowCalls, 1); // A rebuild with the same loaded settings must not restart the tray or // hide an already-running window a second time. await tester.pump(); - expect(trayService.startCalls, 1); + expect(trayService!.startCalls, 1); expect(windowService.hideWindowCalls, 1); }); @@ -1920,15 +1919,8 @@ void main() { linuxWindowServiceProvider.overrideWithValue(windowService), ], child: BusyMaxApp( - trayServiceFactory: - ({ - required windowService, - required labels, - required onOpenAgenda, - }) => trayService = _RecordingTrayService( - windowService, - openAgendaCallback: onOpenAgenda, - ), + trayServiceFactory: (configuration) => + trayService = _RecordingTrayService(configuration), ), ), ); @@ -1944,13 +1936,113 @@ void main() { final command = container.read(scheduleWorkspaceCommandProvider); expect(windowService.showWindowCalls, 1); expect(command?.kind, ScheduleWorkspaceCommandKind.agenda); + expect(command?.date, isNotNull); + expect(command!.date!.year, DateTime.now().year); + expect(command.date!.month, DateTime.now().month); + expect(command.date!.day, DateTime.now().day); + }); + + testWidgets('tray application actions use shared routing and commands', ( + tester, + ) async { + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + final windowService = _RecordingWindowService(); + var syncEnumerations = 0; + final syncScheduler = AllAccountsSyncScheduler( + listSyncEligibleAccounts: () async { + syncEnumerations += 1; + return const []; + }, + syncAccount: (_) async {}, + onSyncFailure: (_) async {}, + interval: Duration.zero, + ); + addTearDown(syncScheduler.dispose); + late _RecordingTrayService trayService; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + buildConfigProvider.overrideWithValue(_missingConfig), + databaseProvider.overrideWithValue(database), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + linuxWindowServiceProvider.overrideWithValue(windowService), + syncSchedulerProvider.overrideWithValue(syncScheduler), + ], + child: BusyMaxApp( + trayServiceFactory: (configuration) => + trayService = _RecordingTrayService(configuration), + ), + ), + ); + await tester.pump(); + await tester.pump(); + final container = ProviderScope.containerOf( + tester.element(find.byType(BusyMaxApp)), + ); + final router = container.read(appRouterProvider); + final initialRoute = router.state.uri.toString(); + + await trayService.configuration.actions.showBusyMax(); + expect(router.state.uri.toString(), initialRoute); + + await trayService.configuration.actions.newEvent(); + expect( + container.read(scheduleWorkspaceCommandProvider)?.kind, + ScheduleWorkspaceCommandKind.newEvent, + ); + + await trayService.configuration.actions.newTask(); + expect( + container.read(scheduleWorkspaceCommandProvider)?.kind, + ScheduleWorkspaceCommandKind.newTask, + ); + + final event = BusyMaxTrayEventEntry( + eventId: 'event-id', + accountId: 'account-id', + calendarSourceId: 'source-id', + title: 'Event', + start: DateTime(2026, 8, 29, 10), + end: DateTime(2026, 8, 29, 11), + allDay: false, + ); + await trayService.configuration.actions.openEvent(event); + final openEvent = container.read(scheduleWorkspaceCommandProvider); + expect(openEvent?.kind, ScheduleWorkspaceCommandKind.openCalendarEvent); + expect(openEvent?.itemId, 'event-id'); + expect(openEvent?.accountId, 'account-id'); + expect(openEvent?.sourceId, 'source-id'); + + await trayService.configuration.actions.openTasksDueToday(); + expect( + container.read(scheduleWorkspaceCommandProvider)?.kind, + ScheduleWorkspaceCommandKind.agenda, + ); + await trayService.configuration.actions.openTodayAgenda(); + expect( + container.read(scheduleWorkspaceCommandProvider)?.kind, + ScheduleWorkspaceCommandKind.agenda, + ); + + await trayService.configuration.actions.openSettings(); + expect(router.state.uri.path, '/settings'); + + final showsBeforeSync = windowService.showWindowCalls; + await trayService.configuration.actions.synchronize(); + expect(syncEnumerations, 1); + expect(windowService.showWindowCalls, showsBeforeSync); + + await trayService.configuration.actions.quitBusyMax(); + expect(windowService.quitAppCalls, 1); }); testWidgets('demo mode retains the local tray entry', (tester) async { final database = AppDatabase.memoryForTests(); addTearDown(database.close); final windowService = _RecordingWindowService(); - final trayService = _RecordingTrayService(windowService); + late _RecordingTrayService trayService; await tester.pumpWidget( ProviderScope( @@ -1961,12 +2053,8 @@ void main() { linuxWindowServiceProvider.overrideWithValue(windowService), ], child: BusyMaxApp( - trayServiceFactory: - ({ - required windowService, - required labels, - required onOpenAgenda, - }) => trayService, + trayServiceFactory: (configuration) => + trayService = _RecordingTrayService(configuration), ), ), ); @@ -1995,7 +2083,7 @@ void main() { await changes.close(); }); final windowService = _RecordingWindowService(); - final trayService = _RecordingTrayService(windowService); + late _RecordingTrayService trayService; await tester.pumpWidget( ProviderScope( @@ -2007,24 +2095,73 @@ void main() { networkConnectivityMonitorProvider.overrideWithValue(monitor), ], child: BusyMaxApp( - trayServiceFactory: - ({ - required windowService, - required labels, - required onOpenAgenda, - }) => trayService, + trayServiceFactory: (configuration) => + trayService = _RecordingTrayService(configuration), ), ), ); await tester.pump(); await tester.pump(); expect(trayService.offlineStates, contains(true)); + final refreshCallsBeforeConnectivityChange = trayService.refreshCalls; changes.add([ConnectivityResult.wifi]); + for (var attempt = 0; attempt < 10; attempt += 1) { + await tester.pump(const Duration(milliseconds: 10)); + if (trayService.offlineStates.isNotEmpty && + trayService.offlineStates.last == false) { + break; + } + } + + expect( + trayService.refreshCalls, + greaterThan(refreshCallsBeforeConnectivityChange), + ); + final currentPresentation = await tester.runAsync( + trayService.configuration.loadPresentation, + ); + expect(currentPresentation!.offline, isFalse); + }); + + testWidgets('tray refreshes after privacy and locale changes', ( + tester, + ) async { + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + final windowService = _RecordingWindowService(); + late _RecordingTrayService trayService; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + buildConfigProvider.overrideWithValue(_missingConfig), + databaseProvider.overrideWithValue(database), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + linuxWindowServiceProvider.overrideWithValue(windowService), + ], + child: BusyMaxApp( + trayServiceFactory: (configuration) => + trayService = _RecordingTrayService(configuration), + ), + ), + ); await tester.pump(); await tester.pump(); + final container = ProviderScope.containerOf( + tester.element(find.byType(BusyMaxApp)), + ); + final settings = container.read(appSettingsControllerProvider.notifier); - expect(trayService.offlineStates.last, isFalse); + var previousRefreshCalls = trayService.refreshCalls; + await settings.setNotificationDetailLevel(NotificationDetailLevel.private); + await tester.pump(); + expect(trayService.refreshCalls, greaterThan(previousRefreshCalls)); + + previousRefreshCalls = trayService.refreshCalls; + await settings.setLocaleTag('de'); + await tester.pump(); + expect(trayService.refreshCalls, greaterThan(previousRefreshCalls)); }); test('production sources avoid forbidden hardcoded accent colors', () { @@ -2045,13 +2182,14 @@ void main() { if (entry is! File) { continue; } + final path = entry.path.replaceAll('\\', '/'); final text = utf8.decode(entry.readAsBytesSync(), allowMalformed: true); - final fileForbidden = entry.path == centralizedAccentMapping + final fileForbidden = path == centralizedAccentMapping ? forbidden : [...forbidden, 'YaruVariant.', 'YaruColors.']; for (final token in fileForbidden) { if (text.contains(token)) { - matches.add('${entry.path}: $token'); + matches.add('$path: $token'); } } } @@ -2324,6 +2462,7 @@ class _DelayedSettingsStore implements LocalSettingsStore { class _RecordingWindowService extends LinuxWindowService { var hideWindowCalls = 0; var showWindowCalls = 0; + var quitAppCalls = 0; @override Future hideWindow() async { @@ -2335,30 +2474,23 @@ class _RecordingWindowService extends LinuxWindowService { showWindowCalls += 1; } + @override + Future quitApp() async { + quitAppCalls += 1; + } + @override Future setHideOnClose(bool enabled) async {} } class _RecordingTrayService extends BusyMaxTrayService { - _RecordingTrayService( - LinuxWindowService windowService, { - Future Function() openAgendaCallback = _noop, - }) : _onOpenAgenda = openAgendaCallback, - super( - windowService: windowService, - labels: const BusyMaxTrayLabels( - openBusyMax: 'Open BusyMax', - agenda: 'Agenda', - quitBusyMax: 'Exit', - offline: 'Offline', - offlineDescription: 'Changes will sync when connected.', - ), - onOpenAgenda: openAgendaCallback, - ); - - final Future Function() _onOpenAgenda; + _RecordingTrayService(this.configuration) + : super(configuration: configuration); + + final BusyMaxTrayServiceConfiguration configuration; var startCalls = 0; + var refreshCalls = 0; var _available = false; final offlineStates = []; @@ -2369,6 +2501,7 @@ class _RecordingTrayService extends BusyMaxTrayService { Future start() async { startCalls += 1; _available = true; + offlineStates.add(configuration.initialPresentation.offline); } @override @@ -2377,18 +2510,16 @@ class _RecordingTrayService extends BusyMaxTrayService { } @override - Future updateLabels(BusyMaxTrayLabels labels) async {} - - @override - Future updateOfflineState(bool offline) async { - offlineStates.add(offline); + Future refreshPresentation() async { + refreshCalls += 1; + final presentation = await configuration.loadPresentation(); + offlineStates.add(presentation.offline); + return true; } - Future openAgenda() => _onOpenAgenda(); + Future openAgenda() => configuration.actions.openTodayAgenda(); } -Future _noop() async {} - const _missingConfig = BuildConfig( googleOAuthClientId: '', googleOAuthClientSecret: '', diff --git a/test/architecture/platform_boundary_test.dart b/test/architecture/platform_boundary_test.dart new file mode 100644 index 00000000..ce61382c --- /dev/null +++ b/test/architecture/platform_boundary_test.dart @@ -0,0 +1,18 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../../tool/check_platform_boundaries.dart' as boundary_check; + +void main() { + test( + 'platform import boundaries remain valid', + () { + final failures = boundary_check.findPlatformBoundaryViolations( + Directory.current, + ); + expect(failures, isEmpty, reason: failures.join('\n')); + }, + timeout: const Timeout(Duration(minutes: 2)), + ); +} diff --git a/test/config/build_config_test.dart b/test/config/build_config_test.dart index a688c9ac..8323ac0d 100644 --- a/test/config/build_config_test.dart +++ b/test/config/build_config_test.dart @@ -22,7 +22,7 @@ void main() { ); }); - test('missing client id has developer run command', () { + test('missing client id has platform-neutral developer guidance', () { const config = BuildConfig( googleOAuthClientId: '', googleOAuthClientSecret: '', @@ -34,7 +34,8 @@ void main() { ); expect(config.hasGoogleOAuthClientId, isFalse); - expect(config.missingClientIdMessage, contains('flutter run -d linux')); + expect(config.missingClientIdMessage, contains('platform entrypoint')); + expect(config.missingClientIdMessage, isNot(contains('linux'))); }); test('demo mode is never enabled in release builds', () { diff --git a/test/core/time/provider_date_time_test.dart b/test/core/time/provider_date_time_test.dart index b742bf73..b23b512a 100644 --- a/test/core/time/provider_date_time_test.dart +++ b/test/core/time/provider_date_time_test.dart @@ -39,4 +39,22 @@ void main() { expect(providerDateTimeAsLocal('2026-06-04', 'UTC'), DateTime(2026, 6, 4)); expect(providerDateTimeIsInstant('2026-06-04', 'UTC'), isFalse); }); + + test('editor wall time respects UTC, offsets, and IANA zones', () { + expect( + providerDateTimeAsWallTime('2026-06-08T09:00:00Z', 'UTC'), + DateTime.utc(2026, 6, 8, 9), + ); + expect( + providerDateTimeAsWallTime( + '2026-06-08T09:00:00-07:00', + 'America/Vancouver', + ), + DateTime(2026, 6, 8, 9), + ); + expect( + providerDateTimeAsWallTime('2026-06-08T16:00:00Z', 'America/Vancouver'), + DateTime(2026, 6, 8, 9), + ); + }); } diff --git a/test/dav/ical/ical_recurrence_test.dart b/test/dav/ical/ical_recurrence_test.dart index 751e687a..70777b4e 100644 --- a/test/dav/ical/ical_recurrence_test.dart +++ b/test/dav/ical/ical_recurrence_test.dart @@ -48,6 +48,62 @@ END:VCALENDAR\r expect(moved.summary, 'Moved'); }); + test('applies THISANDFUTURE while retaining later explicit exceptions', () { + final document = IcalSemanticDocument.parse('''BEGIN:VCALENDAR\r +VERSION:2.0\r +PRODID:-//BusyMax Test//EN\r +BEGIN:VEVENT\r +UID:range@example.test\r +DTSTART;TZID=America/Vancouver:20260803T090000\r +DTEND;TZID=America/Vancouver:20260803T100000\r +RRULE:FREQ=WEEKLY;COUNT=5\r +SUMMARY:Master\r +END:VEVENT\r +BEGIN:VEVENT\r +UID:range@example.test\r +RECURRENCE-ID;TZID=America/Vancouver;RANGE=THISANDFUTURE:20260817T090000\r +DTSTART;TZID=America/Vancouver:20260817T110000\r +DTEND;TZID=America/Vancouver:20260817T123000\r +SUMMARY:Following\r +END:VEVENT\r +BEGIN:VEVENT\r +UID:range@example.test\r +RECURRENCE-ID;TZID=America/Vancouver:20260824T090000\r +DTSTART;TZID=America/Vancouver:20260824T080000\r +DTEND;TZID=America/Vancouver:20260824T090000\r +SUMMARY:Explicit\r +END:VEVENT\r +END:VCALENDAR\r +'''); + + final occurrences = IcalRecurrenceExpander().expand( + document, + rangeStartUtc: DateTime.utc(2026, 8), + rangeEndUtc: DateTime.utc(2026, 9, 8), + ); + + expect(occurrences.map((occurrence) => occurrence.start.rawValue), [ + '20260803T090000', + '20260810T090000', + '20260817T110000', + '20260824T080000', + '20260831T110000', + ]); + expect(occurrences.map((occurrence) => occurrence.summary), [ + 'Master', + 'Master', + 'Following', + 'Explicit', + 'Following', + ]); + expect(occurrences.last.end?.rawValue, '20260831T123000'); + expect(occurrences.last.override, isNull); + expect( + occurrences.last.inheritedOverride?.recurrenceRange, + 'THISANDFUTURE', + ); + }); + test('preserves wall time across a TZID daylight-saving transition', () { final document = IcalSemanticDocument.parse( _eventWithRule( diff --git a/test/dav/ical/ical_task_recurrence_test.dart b/test/dav/ical/ical_task_recurrence_test.dart index 5a2e0d70..44a03c66 100644 --- a/test/dav/ical/ical_task_recurrence_test.dart +++ b/test/dav/ical/ical_task_recurrence_test.dart @@ -45,6 +45,17 @@ void main() { final timed = monthly.withUntilDate('2026-12-31', allDay: false); expect(timed.untilRaw, matches(RegExp(r'^\d{8}T\d{6}Z$'))); expect(timed.untilDate, '2026-12-31'); + expect( + monthly + .withUntilDate( + '2026-12-31', + allDay: false, + floating: true, + baseDate: DateTime(2026, 6, 15, 9, 30), + ) + .untilRaw, + '20261231T093000', + ); }); test('multiple or unknown rules remain opaque and are never rewritten', () { diff --git a/test/dav/mutation/dav_calendar_collection_mutation_service_test.dart b/test/dav/mutation/dav_calendar_collection_mutation_service_test.dart new file mode 100644 index 00000000..81b8a587 --- /dev/null +++ b/test/dav/mutation/dav_calendar_collection_mutation_service_test.dart @@ -0,0 +1,532 @@ +import 'dart:io'; + +import 'package:busymax/src/core/secrets/secret_store.dart'; +import 'package:busymax/src/dav/dav_errors.dart'; +import 'package:busymax/src/dav/discovery/dav_discovery_models.dart'; +import 'package:busymax/src/dav/http/dav_http_transport.dart'; +import 'package:busymax/src/dav/mutation/dav_calendar_collection_mutation_service.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:drift/drift.dart' hide 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'; + +void main() { + late AppDatabase database; + late InMemorySecretStore secrets; + + setUp(() async { + database = AppDatabase(NativeDatabase.memory()); + secrets = InMemorySecretStore(); + await _seedNextcloudAccount(database, secrets); + }); + + tearDown(() => database.close()); + + test('sends an event-only MKCALENDAR beneath the calendar home', () async { + late http.Request request; + var refreshes = 0; + final service = _service( + database, + secrets, + MockClient((incoming) async { + request = incoming; + return http.Response('', HttpStatus.created); + }), + refresh: () async => refreshes += 1, + ); + + final result = await service.createEventCalendar('Work & Travel'); + + expect(result.refreshPending, isFalse); + expect(request.method, 'MKCALENDAR'); + expect( + request.url.toString(), + 'https://cloud.example.test/remote.php/dav/calendars/alex/work-travel/', + ); + expect(request.headers['content-type'], 'application/xml; charset=utf-8'); + expect(request.body, contains('Work & Travel')); + expect(RegExp('name="VEVENT"').allMatches(request.body), hasLength(1)); + expect(request.body, isNot(contains('VTODO'))); + expect(refreshes, 1); + expect(await database.select(database.calendarSources).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + test('uses one MKCALENDAR attempt and never falls back to MKCOL', () async { + final methods = []; + final service = _service( + database, + secrets, + MockClient((request) async { + methods.add(request.method); + return http.Response('', HttpStatus.methodNotAllowed); + }), + ); + + await expectLater( + service.createEventCalendar('Unsupported'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavMkcalendarUnsupported', + ), + ), + ); + expect(methods, ['MKCALENDAR']); + }); + + for (final statusAndExpectation in <(int, DavErrorKind, String)>[ + (401, DavErrorKind.authentication, 'DavAuthRejected'), + (403, DavErrorKind.authorization, 'DavCalendarParentNotWritable'), + (409, DavErrorKind.conflict, 'DavCalendarCollectionConflict'), + ]) { + test('maps HTTP ${statusAndExpectation.$1} to a safe DAV error', () async { + final service = _service( + database, + secrets, + MockClient( + (_) async => http.Response( + 'private response', + statusAndExpectation.$1, + ), + ), + ); + + await expectLater( + service.createEventCalendar('Rejected'), + throwsA( + isA() + .having((error) => error.kind, 'kind', statusAndExpectation.$2) + .having((error) => error.code, 'code', statusAndExpectation.$3) + .having( + (error) => error.toString(), + 'diagnostic', + isNot(contains('private response')), + ), + ), + ); + }); + } + + test( + 'reconciles an ambiguous connection failure with an exact probe', + () async { + final methods = []; + var refreshes = 0; + final service = _service( + database, + secrets, + MockClient((request) async { + methods.add(request.method); + if (request.method == 'MKCALENDAR') { + throw const SocketException('connection closed after commit'); + } + expect(request.headers['depth'], '0'); + expect(request.url.path, endsWith('/recovered/')); + return http.Response(_createdEventCalendar('Recovered'), 207); + }), + refresh: () async => refreshes += 1, + ); + + await service.createEventCalendar('Recovered'); + + expect(methods, ['MKCALENDAR', 'PROPFIND']); + expect(refreshes, 1); + }, + ); + + test( + 'preserves an ambiguous failure when the probe does not match', + () async { + var refreshes = 0; + final service = _service( + database, + secrets, + MockClient((request) async { + if (request.method == 'MKCALENDAR') { + throw const SocketException('connection closed after request'); + } + return http.Response(_createdEventCalendar('Different'), 207); + }), + refresh: () async => refreshes += 1, + ); + + await expectLater( + service.createEventCalendar('Expected'), + throwsA( + isA().having( + (error) => error.kind, + 'kind', + DavErrorKind.network, + ), + ), + ); + expect(refreshes, 0); + }, + ); + + test( + 'malformed ambiguous-outcome probe preserves the original failure', + () async { + final service = _service( + database, + secrets, + MockClient((request) async { + if (request.method == 'MKCALENDAR') { + throw const SocketException('connection closed after request'); + } + return http.Response('', HttpStatus.multiStatus); + }), + ); + + await expectLater( + service.createEventCalendar('Malformed probe'), + throwsA( + isA() + .having((error) => error.kind, 'kind', DavErrorKind.network) + .having((error) => error.code, 'code', 'DavNetworkFailure'), + ), + ); + }, + ); + + test('transport timeout does not retry MKCALENDAR', () async { + var mkcalendarRequests = 0; + final service = _service( + database, + secrets, + MockClient((request) async { + if (request.method == 'MKCALENDAR') mkcalendarRequests += 1; + await Future.delayed(const Duration(milliseconds: 40)); + return http.Response('', HttpStatus.serviceUnavailable); + }), + transportLimits: const DavTransportLimits( + connectTimeout: Duration(milliseconds: 5), + responseTimeout: Duration(milliseconds: 5), + operationTimeout: Duration(milliseconds: 50), + ), + ); + + await expectLater( + service.createEventCalendar('Timeout'), + throwsA( + isA().having( + (error) => error.kind, + 'kind', + DavErrorKind.timeout, + ), + ), + ); + expect(mkcalendarRequests, 1); + }); + + test('only 201 Created is accepted as direct success', () async { + final service = _service( + database, + secrets, + MockClient((_) async => http.Response('', HttpStatus.noContent)), + ); + + await expectLater( + service.createEventCalendar('Unexpected success status'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavCalendarCollectionCreationRejected', + ), + ), + ); + }); + + test('reports created with refresh pending after remote success', () async { + final service = _service( + database, + secrets, + MockClient((_) async => http.Response('', HttpStatus.created)), + refresh: () async => throw StateError('sync unavailable'), + ); + + final result = await service.createEventCalendar('Created remotely'); + + expect( + result.status, + DavCalendarCollectionCreationStatus.createdRefreshPending, + ); + }); + + test( + 'rejects duplicate event-calendar display names before transport', + () async { + await _seedEventCollection(database, displayName: 'Work'); + var requests = 0; + final service = _service( + database, + secrets, + MockClient((_) async { + requests += 1; + return http.Response('', HttpStatus.created); + }), + ); + + await expectLater( + service.createEventCalendar('Work'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavCalendarNameConflict', + ), + ), + ); + expect(requests, 0); + }, + ); + + test('rejects a missing calendar home before transport', () async { + await database.delete(database.davAccountServices).go(); + var requests = 0; + final service = _service( + database, + secrets, + MockClient((_) async { + requests += 1; + return http.Response('', HttpStatus.created); + }), + ); + + await expectLater( + service.createEventCalendar('Work'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavCalendarHomeUnavailable', + ), + ), + ); + expect(requests, 0); + }); + + test( + 'rejects invalid credentials and a mismatched canonical server', + () async { + await secrets.saveCredential( + 'account', + NextcloudSecretRecord( + canonicalServer: Uri.parse('https://different.example.test'), + loginName: 'alex', + appPassword: 'secret', + ), + ); + final service = _service( + database, + secrets, + MockClient((_) async => http.Response('', HttpStatus.created)), + ); + + await expectLater( + service.createEventCalendar('Work'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavCredentialsRevoked', + ), + ), + ); + }, + ); + + test('rejects a cross-origin calendar home before transport', () async { + await database + .update(database.davAccountServices) + .write( + const DavAccountServicesCompanion( + calendarHomeHref: Value( + 'https://evil.example.test/remote.php/dav/calendars/alex/', + ), + ), + ); + var requests = 0; + final service = _service( + database, + secrets, + MockClient((_) async { + requests += 1; + return http.Response('', HttpStatus.created); + }), + ); + + await expectLater( + service.createEventCalendar('Work'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavCalendarMutationContextInvalid', + ), + ), + ); + expect(requests, 0); + }); + + test('enforces offline state before context or transport work', () async { + var networkChecks = 0; + var requests = 0; + final service = _service( + database, + secrets, + MockClient((_) async { + requests += 1; + return http.Response('', HttpStatus.created); + }), + requireNetwork: () async { + networkChecks += 1; + throw const _OfflineTestException(); + }, + ); + + await expectLater( + service.createEventCalendar('Offline'), + throwsA(isA<_OfflineTestException>()), + ); + expect(networkChecks, 1); + expect(requests, 0); + }); + + test('honors cancellation without probing or refreshing', () async { + final token = DavCancellationToken()..cancel(); + var requests = 0; + var refreshes = 0; + final service = _service( + database, + secrets, + MockClient((_) async { + requests += 1; + return http.Response('', HttpStatus.created); + }), + refresh: () async => refreshes += 1, + ); + + await expectLater( + service.createEventCalendar('Cancelled', cancellationToken: token), + throwsA( + isA().having( + (error) => error.kind, + 'kind', + DavErrorKind.cancelled, + ), + ), + ); + expect(requests, 0); + expect(refreshes, 0); + }); +} + +DavCalendarCollectionMutationService _service( + AppDatabase database, + SecretStore secrets, + http.Client client, { + Future Function()? refresh, + Future Function()? requireNetwork, + DavTransportLimits transportLimits = const DavTransportLimits(), +}) { + var correlation = 0; + return DavCalendarCollectionMutationService( + database: database, + secretStore: secrets, + httpClient: client, + accountId: 'account', + refreshAfterMutation: refresh ?? () async {}, + requireNetwork: requireNetwork, + transportLimits: transportLimits, + correlationIdFactory: () => 'correlation-${correlation += 1}', + ); +} + +Future _seedNextcloudAccount( + AppDatabase database, + InMemorySecretStore secrets, +) async { + const now = '2026-08-29T12:00:00.000Z'; + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'account', + provider: 'nextcloud', + authority: 'https://cloud.example.test', + providerAccountId: 'alex', + credentialKind: 'nextcloud_app_password', + createdAtUtc: now, + updatedAtUtc: now, + ), + ); + await database + .into(database.davAccountServices) + .insert( + DavAccountServicesCompanion.insert( + accountId: 'account', + canonicalServiceUri: 'https://cloud.example.test/remote.php/dav/', + canonicalOrigin: 'https://cloud.example.test', + principalHref: const Value('/remote.php/dav/principals/users/alex/'), + calendarHomeHref: const Value( + 'https://cloud.example.test/remote.php/dav/calendars/alex/', + ), + discoveredAtUtc: now, + ), + ); + await secrets.saveCredential( + 'account', + NextcloudSecretRecord( + canonicalServer: Uri.parse('https://cloud.example.test'), + loginName: 'alex', + appPassword: 'application-password', + ), + ); +} + +Future _seedEventCollection( + AppDatabase database, { + required String displayName, +}) async { + const now = '2026-08-29T12:00:00.000Z'; + await database + .into(database.davCollections) + .insert( + DavCollectionsCompanion.insert( + id: 'calendar', + accountId: 'account', + hrefKey: '/remote.php/dav/calendars/alex/work/', + requestUri: + 'https://cloud.example.test/remote.php/dav/calendars/alex/work/', + displayName: displayName, + supportedComponentMask: const Value(davComponentEvent), + eventProjectionEnabled: const Value(true), + createdAtUtc: now, + updatedAtUtc: now, + ), + ); +} + +String _createdEventCalendar(String displayName) => + '' + '' + '/remote.php/dav/calendars/alex/recovered/' + '' + '' + '$displayName' + '' + '' + 'HTTP/1.1 200 OK' + ''; + +final class _OfflineTestException implements Exception { + const _OfflineTestException(); +} diff --git a/test/dav/mutation/dav_mutation_patch_test.dart b/test/dav/mutation/dav_mutation_patch_test.dart index 63d0bd19..efa26c55 100644 --- a/test/dav/mutation/dav_mutation_patch_test.dart +++ b/test/dav/mutation/dav_mutation_patch_test.dart @@ -119,6 +119,81 @@ void main() { expect(result, contains('X-ALARM-UNKNOWN:keep')); }); + test('new DAV event restores a projected alarm without losing fields', () { + final object = buildDavEventObject( + DavEventMutationInput( + title: 'Copied event', + allDay: false, + start: DateTime.utc(2026, 8, 8, 9), + end: DateTime.utc(2026, 8, 8, 10), + reminders: const { + 'minutes': [17], + 'alarms': [ + { + 'properties': [ + {'name': 'ACTION', 'value': 'DISPLAY'}, + {'name': 'DESCRIPTION', 'value': 'Imported reminder'}, + {'name': 'TRIGGER', 'value': '-PT17M'}, + {'name': 'X-ALARM-KEEP', 'value': 'opaque'}, + ], + }, + ], + }, + ), + idFactory: () => 'copied-event', + nowUtc: () => DateTime.utc(2026, 8, 8, 12), + ); + + expect(object.rawIcs, contains('DESCRIPTION:Imported reminder')); + expect(object.rawIcs, contains('TRIGGER:-PT17M')); + expect(object.rawIcs, contains('X-ALARM-KEEP:opaque')); + expect( + IcalSemanticDocument.parse(object.rawIcs).components.single.alarms, + hasLength(1), + ); + }); + + test('event reminder edit replaces editable alarms and keeps siblings', () { + final baseline = _baselineEvent.replaceFirst( + 'BEGIN:VALARM\r\nACTION:AUDIO', + 'BEGIN:VALARM\r\n' + 'ACTION:DISPLAY\r\n' + 'TRIGGER:-PT30M\r\n' + 'DESCRIPTION:Second reminder\r\n' + 'END:VALARM\r\n' + 'BEGIN:VALARM\r\n' + 'ACTION: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 { + 'overrides': [ + {'minutes': 45}, + ], + }, + remindersChanged: true, + ), + ); + final result = patch!.applyTo(baseline, nowUtc: DateTime.utc(2026, 8, 8)); + + expect('ACTION:DISPLAY'.allMatches(result), hasLength(1)); + expect(result, contains('TRIGGER:-PT45M')); + expect(result, isNot(contains('TRIGGER:-PT15M'))); + expect(result, isNot(contains('TRIGGER:-PT30M'))); + expect(result, contains('ACTION:AUDIO')); + expect(result, contains('X-ALARM-UNKNOWN:keep')); + }); + test('UTC task dates preserve the entered wall-clock value', () { final object = buildDavTaskObject( const { @@ -444,6 +519,66 @@ END:VCALENDAR\r expect(candidate, contains('X-UNKNOWN;X-PARAM="a,b":keep-me')); }); + test('this-and-following edit writes an RFC 5545 range exception', () { + final patch = buildDavEventOccurrenceExceptionPatch( + uid: 'event@example.test', + occurrenceKey: 'TZID=America/Vancouver:20260817T090000', + baselineRawIcs: _baselineEvent, + input: DavEventMutationInput( + title: 'Following occurrences', + allDay: false, + start: DateTime(2026, 8, 17, 11), + end: DateTime(2026, 8, 17, 12, 30), + startTimeZone: 'America/Vancouver', + endTimeZone: 'America/Vancouver', + ), + thisAndFuture: true, + nowUtc: () => DateTime.utc(2026, 8, 8, 12), + ); + final candidate = patch.applyTo( + _baselineEvent, + nowUtc: DateTime.utc(2026, 8, 8, 12), + ); + final range = IcalSemanticDocument.parse( + candidate, + ).components.singleWhere((component) => component.recurrenceRange != null); + + expect(range.recurrenceRange, 'THISANDFUTURE'); + expect(range.recurrenceId?.rawValue, '20260817T090000'); + expect(range.start?.rawValue, '20260817T110000'); + expect(range.end?.rawValue, '20260817T123000'); + expect(candidate, contains('RANGE=THISANDFUTURE')); + }); + + test('this-and-following edit promotes an existing exception to a range', () { + final patch = buildDavEventOccurrenceExceptionPatch( + uid: 'event@example.test', + occurrenceKey: 'TZID=America/Vancouver:20260810T090000', + baselineRawIcs: _baselineEvent, + input: DavEventMutationInput( + title: 'Promoted exception', + allDay: false, + start: DateTime(2026, 8, 10, 13), + end: DateTime(2026, 8, 10, 14), + startTimeZone: 'America/Vancouver', + endTimeZone: 'America/Vancouver', + ), + thisAndFuture: true, + ); + final candidate = patch.applyTo( + _baselineEvent, + nowUtc: DateTime.utc(2026, 8, 8, 12), + ); + final semantic = IcalSemanticDocument.parse(candidate); + final exception = semantic.components.singleWhere( + (component) => component.recurrenceId != null, + ); + + expect(semantic.components, hasLength(2)); + expect(exception.recurrenceRange, 'THISANDFUTURE'); + expect(exception.summary, 'Promoted exception'); + }); + test('one-occurrence delete creates or updates a cancelled exception', () { final generated = buildDavEventOccurrenceCancellationPatch( uid: 'event@example.test', @@ -466,6 +601,23 @@ END:VCALENDAR\r expect('BEGIN:VEVENT'.allMatches(existing), hasLength(2)); }); + test('this-and-following delete writes a cancelled range exception', () { + final candidate = buildDavEventOccurrenceCancellationPatch( + uid: 'event@example.test', + occurrenceKey: 'TZID=America/Vancouver:20260817T090000', + baselineRawIcs: _baselineEvent, + thisAndFuture: true, + nowUtc: () => DateTime.utc(2026, 8, 8, 12), + ).applyTo(_baselineEvent, nowUtc: DateTime.utc(2026, 8, 8, 12)); + final exception = IcalSemanticDocument.parse(candidate).components + .singleWhere( + (component) => component.recurrenceRange == 'THISANDFUTURE', + ); + + expect(exception.status, 'CANCELLED'); + expect(exception.recurrenceRange, 'THISANDFUTURE'); + }); + test( 'three-way merge applies disjoint local change to current server body', () { diff --git a/test/dav/mutation/dav_repository_mutation_integration_test.dart b/test/dav/mutation/dav_repository_mutation_integration_test.dart index 3f13aba7..2fe318bd 100644 --- a/test/dav/mutation/dav_repository_mutation_integration_test.dart +++ b/test/dav/mutation/dav_repository_mutation_integration_test.dart @@ -142,6 +142,56 @@ void main() { }, ); + test('same-account Nextcloud event move uses WebDAV MOVE', () async { + await _seedDestinationCalendar(database); + const href = '${_collectionHref}move-event.ics'; + const keep = 'X-KEEP-EXACT;X-QUOTE="a,b":opaque'; + const alarm = [ + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:Imported reminder', + 'TRIGGER:-PT17M', + 'X-ALARM-KEEP:opaque', + 'END:VALARM', + ]; + final baseline = _eventResource( + uid: 'move-event@example.test', + summary: 'Server title', + extra: const [keep, ...alarm], + ); + await _commitObjects(objectRepository, [ + _prepared(href: href, etag: '"move-event"', body: baseline), + ]); + final event = await database.select(database.calendarEvents).getSingle(); + + await calendarRepository.updateLocalEvent( + _draftForEvent(event).copyWith( + sourceId: _destinationCalendarSourceId, + providerCalendarId: _destinationCollectionHref, + title: 'Moved title', + ), + ); + + final operation = await database.select(database.pendingOps).getSingle(); + expect(operation.operationType, 'dav.move'); + expect(operation.davCollectionId, _collectionId); + expect(operation.destinationCollectionId, _destinationCollectionId); + expect(operation.baselineRawIcs, baseline); + final patch = DavMutationPatch.fromJsonString(operation.mutationPatchJson!); + final movedRaw = patch.applyTo(operation.baselineRawIcs!, nowUtc: _now); + expect(movedRaw, contains('SUMMARY:Moved title')); + expect(movedRaw, contains(keep)); + expect(movedRaw, contains(alarm.join('\r\n'))); + + final projected = await database + .select(database.calendarEvents) + .getSingle(); + expect(projected.calendarSourceId, _destinationCalendarSourceId); + expect(projected.providerCalendarId, _destinationCollectionHref); + expect(projected.davCollectionId, _destinationCollectionId); + expect(projected.syncStatus, 'pending'); + }); + test( 'DAV event attendee mutation is rejected without local side effects', () async { @@ -262,6 +312,51 @@ void main() { expect(candidate, contains('X-SERIES-KEEP:opaque')); }); + test( + 'this-and-following DAV edit projects the RFC range immediately', + () async { + const href = '${_collectionHref}range-edit.ics'; + final baseline = _recurringEventResource(); + await _commitObjects(objectRepository, [ + _prepared(href: href, etag: '"series-etag"', body: baseline), + ]); + final occurrence = (await database.select(database.calendarEvents).get()) + .singleWhere((event) => event.recurrenceIdKey != null); + + await calendarRepository.updateLocalEvent( + _draftForEvent(occurrence).copyWith( + title: 'Changed following events', + start: DateTime.utc(2026, 8, 9, 13), + end: DateTime.utc(2026, 8, 9, 14), + recurringMutationScope: RecurringEventMutationScope.thisAndFuture, + ), + ); + + final operation = await database.select(database.pendingOps).getSingle(); + final patch = DavMutationPatch.fromJsonString( + operation.mutationPatchJson!, + ); + final candidate = patch.applyTo(baseline, nowUtc: _now); + final range = IcalSemanticDocument.parse( + candidate, + ).components.singleWhere((component) => component.recurrenceId != null); + expect(range.recurrenceRange, 'THISANDFUTURE'); + + final projected = await database.select(database.calendarEvents).get(); + final target = projected.singleWhere( + (event) => event.occurrenceKey!.contains('2026-08-09T09:00:00'), + ); + final following = projected.singleWhere( + (event) => event.occurrenceKey!.contains('2026-08-10T09:00:00'), + ); + expect(target.title, 'Changed following events'); + expect(target.startDateTime, '2026-08-09T13:00:00.000Z'); + expect(following.title, 'Changed following events'); + expect(following.startDateTime, '2026-08-10T13:00:00.000Z'); + expect(following.recurrenceIdKey, isNull); + }, + ); + test( 'entire-series edit patches the master anchor and preserves exceptions', () async { @@ -346,6 +441,44 @@ void main() { }, ); + test('this-and-following DAV delete cancels the projected range', () async { + const href = '${_collectionHref}range-delete.ics'; + final baseline = _recurringEventResource(); + await _commitObjects(objectRepository, [ + _prepared(href: href, etag: '"series-etag"', body: baseline), + ]); + final occurrence = (await database.select(database.calendarEvents).get()) + .singleWhere((event) => event.recurrenceIdKey != null); + + await calendarRepository.deleteLocalEvent( + occurrence.id, + recurringScope: RecurringEventMutationScope.thisAndFuture, + ); + + final operation = await database.select(database.pendingOps).getSingle(); + expect(operation.operationType, 'dav.update'); + final candidate = DavMutationPatch.fromJsonString( + operation.mutationPatchJson!, + ).applyTo(baseline, nowUtc: _now); + final exception = IcalSemanticDocument.parse( + candidate, + ).components.singleWhere((component) => component.recurrenceId != null); + expect(exception.status, 'CANCELLED'); + expect(exception.recurrenceRange, 'THISANDFUTURE'); + + final projected = await database.select(database.calendarEvents).get(); + expect( + projected + .where( + (event) => + event.occurrenceKey!.contains('2026-08-09T09:00:00') || + event.occurrenceKey!.contains('2026-08-10T09:00:00'), + ) + .every((event) => event.isCancelled), + isTrue, + ); + }); + test( 'event delete removes only VEVENT when an unknown sibling is present', () async { @@ -1198,6 +1331,50 @@ Future _seedDestinationTaskList(AppDatabase database) async { ); } +Future _seedDestinationCalendar(AppDatabase database) async { + final now = _now.toIso8601String(); + await database + .into(database.davCollections) + .insert( + DavCollectionsCompanion.insert( + id: _destinationCollectionId, + accountId: _accountId, + hrefKey: _destinationCollectionHref, + requestUri: 'https://cloud.example.test$_destinationCollectionHref', + displayName: 'Home', + supportedComponentMask: const Value(1), + supportedReportsJson: Value( + jsonEncode([ + '{DAV:}sync-collection', + '{urn:ietf:params:xml:ns:caldav}calendar-multiget', + ]), + ), + currentUserPrivilegesJson: Value( + jsonEncode(['{DAV:}read', '{DAV:}write']), + ), + readOnly: const Value(false), + eventProjectionEnabled: const Value(true), + taskProjectionEnabled: const Value(false), + createdAtUtc: now, + updatedAtUtc: now, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: _destinationCalendarSourceId, + accountId: _accountId, + provider: 'nextcloud', + providerCalendarId: _destinationCollectionHref, + davCollectionId: const Value(_destinationCollectionId), + summary: 'Home', + createdAtLocal: _now.millisecondsSinceEpoch, + updatedAtLocal: _now.millisecondsSinceEpoch, + ), + ); +} + String _eventResource({ required String uid, required String summary, @@ -1301,5 +1478,6 @@ const _taskListId = 'dav-task-list-collection'; const _destinationCollectionId = 'destination-collection'; const _destinationCollectionHref = '/remote.php/dav/calendars/alex/home/'; const _destinationTaskListId = 'dav-task-list-destination-collection'; +const _destinationCalendarSourceId = 'dav-calendar-destination-collection'; final _now = DateTime.utc(2026, 8, 8, 12); final _later = DateTime.utc(2026, 8, 9, 12); diff --git a/test/dav/mutation/dav_task_list_mutation_service_test.dart b/test/dav/mutation/dav_task_list_mutation_service_test.dart index b48497cd..3b0f5e68 100644 --- a/test/dav/mutation/dav_task_list_mutation_service_test.dart +++ b/test/dav/mutation/dav_task_list_mutation_service_test.dart @@ -93,6 +93,32 @@ void main() { }, ); + test('create enforces network availability before DAV transport', () async { + var requests = 0; + var networkChecks = 0; + final service = DavTaskListMutationService( + database: database, + secretStore: secrets, + httpClient: MockClient((_) async { + requests += 1; + return http.Response('', HttpStatus.created); + }), + accountId: 'account', + refreshAfterMutation: () async {}, + requireNetwork: () async { + networkChecks += 1; + throw const _OfflineTestException(); + }, + ); + + await expectLater( + service.createTaskList('Offline'), + throwsA(isA<_OfflineTestException>()), + ); + expect(networkChecks, 1); + expect(requests, 0); + }); + test('a lost MKCOL response is reconciled by a matching PROPFIND', () async { var requests = 0; var refreshes = 0; @@ -334,3 +360,7 @@ const _forbiddenProppatch = '' 'HTTP/1.1 403 Forbidden' ''; + +final class _OfflineTestException implements Exception { + const _OfflineTestException(); +} diff --git a/test/dav/sync/dav_account_sync_engine_test.dart b/test/dav/sync/dav_account_sync_engine_test.dart index 327601b9..cb1f3d79 100644 --- a/test/dav/sync/dav_account_sync_engine_test.dart +++ b/test/dav/sync/dav_account_sync_engine_test.dart @@ -8,6 +8,7 @@ import 'package:busymax/src/dav/mutation/dav_mutation_patch.dart'; import 'package:busymax/src/dav/mutation/dav_pending_operations.dart'; import 'package:busymax/src/dav/sync/dav_account_sync_engine.dart'; import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/notifications/notification_schedule_service.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -206,6 +207,138 @@ void main() { }, ); + test( + 'removed collection rebuilds reminders without object-level changes', + () async { + const eventId = 'nextcloud-event'; + await database + .into(database.calendarEvents) + .insert( + CalendarEventsCompanion.insert( + id: eventId, + accountId: 'account', + calendarSourceId: 'dav-calendar-collection', + provider: 'nextcloud', + providerCalendarId: _collectionHref, + providerEventId: 'event@example.test', + title: 'Removed event', + startDateTime: const Value('2026-08-09T09:00:00.000Z'), + startTimeZone: const Value('UTC'), + endDateTime: const Value('2026-08-09T10:00:00.000Z'), + endTimeZone: const Value('UTC'), + createdAtLocal: _now.millisecondsSinceEpoch, + updatedAtLocal: _now.millisecondsSinceEpoch, + ), + ); + await database + .into(database.tasks) + .insert( + TasksCompanion.insert( + accountId: 'account', + taskListId: 'dav-task-list-collection', + id: 'nextcloud-task', + davCollectionId: const Value('collection'), + title: 'Removed task', + taskAlarmsJson: const Value( + '[{"trigger":{"kind":"absolute",' + '"dateTime":"2026-08-09T08:30:00.000Z"}}]', + ), + rawJson: '{}', + createdLocalAtUtc: _now.toIso8601String(), + updatedLocalAtUtc: _now.toIso8601String(), + ), + ); + await database.batch( + (batch) => batch.insertAll(database.notificationSchedule, [ + for (final reminder in const [ + ( + id: 'event|nextcloud-event|display:30', + sourceType: 'event', + sourceId: eventId, + title: 'Removed event', + ), + ( + id: 'task|account|dav-task-list-collection|nextcloud-task', + sourceType: 'task', + sourceId: 'nextcloud-task', + title: 'Removed task', + ), + ]) + NotificationScheduleCompanion.insert( + id: reminder.id, + accountId: 'account', + sourceType: reminder.sourceType, + sourceId: reminder.sourceId, + scheduledAtUtc: DateTime.utc( + 2026, + 8, + 9, + 8, + 30, + ).millisecondsSinceEpoch, + title: reminder.title, + createdAtLocal: _now.millisecondsSinceEpoch, + updatedAtLocal: _now.millisecondsSinceEpoch, + ), + ]), + ); + var requestIndex = 0; + final client = MockClient((request) async { + final response = switch (requestIndex) { + 0 => http.Response( + '', + 200, + headers: { + 'dav': '1, 3, calendar-access, sync-collection', + 'allow': 'OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE', + }, + ), + 1 => _discoveryMultistatus(_currentPrincipalResponse), + 2 => _discoveryMultistatus(_principalPropertiesResponse), + 3 => _discoveryMultistatus(_emptyInventoryResponse), + _ => throw StateError('Unexpected discovery request.'), + }; + requestIndex += 1; + return response; + }); + var rebuildCalls = 0; + final result = await DavAccountSyncEngine( + database: database, + secretStore: secrets, + httpClient: client, + accountId: 'account', + correlationIdFactory: () => 'removed-collection', + nowUtc: () => _now, + rebuildNotifications: (accountId, affectedObjectIds) async { + rebuildCalls += 1; + expect(accountId, 'account'); + expect(affectedObjectIds, isEmpty); + await NotificationScheduleService( + database: database, + nowUtc: () => _now, + ).rebuildUpcomingNotifications(accountId); + }, + ).synchronize(full: true); + + expect(requestIndex, 4); + expect(result.discoveryRefreshed, isTrue); + expect(result.affectedObjectIds, isEmpty); + expect(rebuildCalls, 1); + expect( + (await database.select(database.calendarSources).getSingle()).isDeleted, + isTrue, + ); + expect( + (await database.select(database.taskLists).getSingle()).serverMissing, + isTrue, + ); + expect( + await database.select(database.notificationSchedule).get(), + isEmpty, + ); + }, + ); + test('credential mismatch maps to reauthentication-required', () async { await secrets.saveCredential( 'account', @@ -415,3 +548,33 @@ SUMMARY:$summary\r END:VEVENT\r END:VCALENDAR\r '''; + +http.Response _discoveryMultistatus(String body) => http.Response( + body, + 207, + headers: {'content-type': 'application/xml; charset=utf-8'}, +); + +const _currentPrincipalResponse = ''' + + /cloud/.well-known/caldav + /cloud/remote.php/dav/principals/users/alex/ + HTTP/1.1 200 OK +'''; + +const _principalPropertiesResponse = ''' + + /cloud/remote.php/dav/principals/users/alex/ + + /cloud/remote.php/dav/calendars/alex/ + HTTP/1.1 200 OK + +'''; + +const _emptyInventoryResponse = ''' + + /cloud/remote.php/dav/calendars/alex/ + + HTTP/1.1 200 OK + +'''; diff --git a/test/db/app_database_test.dart b/test/db/app_database_test.dart index 42dc9f1f..807b068e 100644 --- a/test/db/app_database_test.dart +++ b/test/db/app_database_test.dart @@ -21,7 +21,7 @@ void main() { await database.close(); }); - test('opens schema version 9 and creates required indexes', () async { + test('opens schema version 13 and creates required indexes', () async { final version = await database .customSelect('PRAGMA user_version') .getSingle(); @@ -38,8 +38,14 @@ void main() { final calendarSourceColumns = await database .customSelect('PRAGMA table_info(calendar_sources)') .get(); + final taskListColumns = await database + .customSelect('PRAGMA table_info(task_lists)') + .get(); + final webCalColumns = await database + .customSelect('PRAGMA table_info(web_cal_subscriptions)') + .get(); - expect(version.data['user_version'], 9); + expect(version.data['user_version'], 13); expect( taskColumns.map((row) => row.read('name')), contains('microsoft_checklist_items_json'), @@ -48,6 +54,14 @@ void main() { calendarSourceColumns.map((row) => row.read('name')), contains('reminders_enabled'), ); + expect( + taskListColumns.map((row) => row.read('name')), + contains('reminders_enabled'), + ); + expect( + webCalColumns.map((row) => row.read('name')), + containsAll(['projection_range_start_utc', 'projection_range_end_utc']), + ); expect(indexes.map((row) => row.data['name']).toSet(), { 'idx_accounts_provider', 'idx_accounts_remote_identity', @@ -75,9 +89,286 @@ void main() { 'idx_sync_cursors_scope', 'idx_pending_ops_dav_replay', 'idx_notification_schedule_due', + 'idx_webcal_subscriptions_due', }); }); + test('schema 10 migration preserves accounts and enables WebCal', () async { + await database.close(); + final tempDir = await Directory.systemTemp.createTemp( + 'busymax-db-v10-test-', + ); + final file = File('${tempDir.path}/busymax.sqlite'); + final schemaTenDatabase = AppDatabase(NativeDatabase(file)); + await _insertAccount(schemaTenDatabase); + await schemaTenDatabase.close(); + + final raw = sqlite3.sqlite3.open(file.path); + try { + raw.execute('DROP TABLE ical_import_receipts'); + raw.execute('DROP TABLE web_cal_subscriptions'); + raw.execute('PRAGMA user_version = 10'); + } finally { + raw.close(); + } + + database = AppDatabase(NativeDatabase(file)); + final existing = await (database.select( + database.accounts, + )..where((row) => row.id.equals('account'))).getSingle(); + expect(existing.provider, 'google'); + expect( + (await database.customSelect('PRAGMA user_version').getSingle()) + .read('user_version'), + 13, + ); + + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'webcal-account-subscription', + provider: 'webcal', + authority: 'https://calendar.example.test', + providerAccountId: 'fingerprint', + credentialKind: 'webcal_subscription', + authState: const Value('signed_in'), + calendarsEnabled: const Value(true), + tasksEnabled: const Value(false), + grantedScopes: const Value(''), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + expect( + await database.customSelect('PRAGMA foreign_key_check').get(), + isEmpty, + ); + + await database.close(); + database = AppDatabase(NativeDatabase.memory()); + await tempDir.delete(recursive: true); + }); + + test( + 'schema 11 migration enables task-list reminders without data loss', + () async { + await database.close(); + final tempDir = await Directory.systemTemp.createTemp( + 'busymax-db-v11-test-', + ); + final file = File('${tempDir.path}/busymax.sqlite'); + final schemaElevenDatabase = AppDatabase(NativeDatabase(file)); + await _insertAccount(schemaElevenDatabase); + await schemaElevenDatabase.taskListsDao.upsertTaskList( + _taskList(id: 'list-1', title: 'Existing list'), + ); + await schemaElevenDatabase.close(); + + final raw = sqlite3.sqlite3.open(file.path); + try { + raw.execute('ALTER TABLE task_lists DROP COLUMN reminders_enabled'); + raw.execute('PRAGMA user_version = 11'); + } finally { + raw.close(); + } + + database = AppDatabase(NativeDatabase(file)); + final version = await database + .customSelect('PRAGMA user_version') + .getSingle(); + final list = await (database.select( + database.taskLists, + )..where((row) => row.id.equals('list-1'))).getSingle(); + + expect(version.read('user_version'), 13); + expect(list.title, 'Existing list'); + expect(list.remindersEnabled, isTrue); + expect( + await database.customSelect('PRAGMA foreign_key_check').get(), + isEmpty, + ); + + await database.close(); + database = AppDatabase(NativeDatabase.memory()); + await tempDir.delete(recursive: true); + }, + ); + + test( + 'schema 12 migration adds WebCal coverage and account pair constraints', + () async { + await database.close(); + final tempDir = await Directory.systemTemp.createTemp( + 'busymax-db-v12-test-', + ); + final file = File('${tempDir.path}/busymax.sqlite'); + final schemaTwelveDatabase = AppDatabase(NativeDatabase(file)); + await _insertAccount(schemaTwelveDatabase); + await schemaTwelveDatabase.close(); + + final raw = sqlite3.sqlite3.open(file.path); + try { + raw.execute( + 'ALTER TABLE web_cal_subscriptions ' + 'DROP COLUMN projection_range_start_utc', + ); + raw.execute( + 'ALTER TABLE web_cal_subscriptions ' + 'DROP COLUMN projection_range_end_utc', + ); + _removeProviderCredentialPairConstraint(raw); + raw.execute('PRAGMA user_version = 12'); + } finally { + raw.close(); + } + + database = AppDatabase(NativeDatabase(file)); + final columns = await database + .customSelect('PRAGMA table_info(web_cal_subscriptions)') + .get(); + expect( + (await database.customSelect('PRAGMA user_version').getSingle()) + .read('user_version'), + 13, + ); + expect( + columns.map((row) => row.read('name')), + containsAll(['projection_range_start_utc', 'projection_range_end_utc']), + ); + await expectLater( + database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'invalid-pair', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'invalid', + credentialKind: 'webcal_subscription', + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ), + throwsA(anything), + ); + expect( + await database.customSelect('PRAGMA foreign_key_check').get(), + isEmpty, + ); + + await database.close(); + database = AppDatabase(NativeDatabase.memory()); + await tempDir.delete(recursive: true); + }, + ); + + test( + 'schema 12 migration rejects a corrupt provider credential pair', + () async { + await database.close(); + final tempDir = await Directory.systemTemp.createTemp( + 'busymax-db-v12-corrupt-test-', + ); + final file = File('${tempDir.path}/busymax.sqlite'); + final schemaTwelveDatabase = AppDatabase(NativeDatabase(file)); + await _insertAccount(schemaTwelveDatabase); + await schemaTwelveDatabase.close(); + + final raw = sqlite3.sqlite3.open(file.path); + try { + raw.execute('PRAGMA ignore_check_constraints = ON'); + raw.execute( + "UPDATE accounts SET credential_kind = 'webcal_subscription' " + "WHERE id = 'account'", + ); + raw.execute('PRAGMA user_version = 12'); + } finally { + raw.close(); + } + + database = AppDatabase(NativeDatabase(file)); + await expectLater( + database.customSelect('SELECT 1').get(), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'account_identity_invariant_failed', + ), + ), + ); + + await database.close(); + database = AppDatabase(NativeDatabase.memory()); + await tempDir.delete(recursive: true); + }, + ); + + test( + 'schema 9 migration adds calendar ownership columns without data loss', + () async { + await database.close(); + final tempDir = await Directory.systemTemp.createTemp( + 'busymax-db-v9-test-', + ); + final file = File('${tempDir.path}/busymax.sqlite'); + final schemaNineDatabase = AppDatabase(NativeDatabase(file)); + await _insertAccount(schemaNineDatabase); + await schemaNineDatabase + .into(schemaNineDatabase.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'calendar', + accountId: 'account', + provider: 'google', + providerCalendarId: 'shared@example.com', + summary: 'Shared calendar', + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + await schemaNineDatabase.close(); + + final raw = sqlite3.sqlite3.open(file.path); + try { + raw.execute('ALTER TABLE calendar_sources DROP COLUMN data_owner'); + raw.execute('ALTER TABLE calendar_sources DROP COLUMN is_removable'); + raw.execute('PRAGMA user_version = 9'); + } finally { + raw.close(); + } + + database = AppDatabase(NativeDatabase(file)); + final version = await database + .customSelect('PRAGMA user_version') + .getSingle(); + final columns = await database + .customSelect('PRAGMA table_info(calendar_sources)') + .get(); + final source = await (database.select( + database.calendarSources, + )..where((row) => row.id.equals('calendar'))).getSingle(); + + expect(version.read('user_version'), 13); + expect( + columns.map((row) => row.read('name')), + containsAll(['data_owner', 'is_removable']), + ); + expect(source.summary, 'Shared calendar'); + expect(source.dataOwner, equals(null)); + expect(source.isRemovable, equals(null)); + expect( + await database.customSelect('PRAGMA foreign_key_check').get(), + isEmpty, + ); + + await database.close(); + database = AppDatabase(NativeDatabase.memory()); + await tempDir.delete(recursive: true); + }, + ); + test('upserts task lists and preserves raw JSON', () async { await _insertAccount(database); @@ -259,7 +550,7 @@ void main() { final version = await database .customSelect('PRAGMA user_version') .getSingle(); - expect(version.read('user_version'), 9); + expect(version.read('user_version'), 13); final accounts = await database.select(database.accounts).get(); expect(accounts, hasLength(2)); @@ -460,7 +751,7 @@ void main() { .getSingle(); final op = await database.pendingOpsDao.getOp('op-1'); - expect(version.data['user_version'], 9); + expect(version.data['user_version'], 13); expect(op, isNot(equals(null))); expect(op!.baselineRawJson, equals(null)); @@ -538,6 +829,74 @@ PendingOpsCompanion _pendingOp({ const _now = '2026-06-04T00:00:00.000Z'; +void _removeProviderCredentialPairConstraint(sqlite3.Database database) { + final sql = + database + .select( + "SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'accounts'", + ) + .single['sql'] + as String; + const marker = + "CHECK ((provider IN ('google', 'microsoft') AND credential_kind = 'oauth')"; + final start = sql.indexOf(marker); + if (start < 0) { + throw StateError('Provider credential constraint fixture not found.'); + } + + var quoted = false; + var depth = 0; + var opened = false; + int? end; + for (var index = start; index < sql.length; index += 1) { + final character = sql[index]; + if (character == "'") { + if (quoted && index + 1 < sql.length && sql[index + 1] == "'") { + index += 1; + } else { + quoted = !quoted; + } + continue; + } + if (quoted) continue; + if (character == '(') { + opened = true; + depth += 1; + } else if (character == ')') { + depth -= 1; + if (opened && depth == 0) { + end = index + 1; + break; + } + } + } + if (end == null) { + throw StateError('Provider credential constraint fixture is malformed.'); + } + + var removalStart = start; + while (removalStart > 0 && + (sql[removalStart - 1] == ' ' || sql[removalStart - 1] == '\n')) { + removalStart -= 1; + } + if (removalStart > 0 && sql[removalStart - 1] == ',') { + removalStart -= 1; + } + final legacySql = sql.replaceRange(removalStart, end, ''); + database.execute('PRAGMA writable_schema = ON'); + try { + database.execute( + "UPDATE sqlite_schema SET sql = ? WHERE type = 'table' AND name = 'accounts'", + [legacySql], + ); + } finally { + database.execute('PRAGMA writable_schema = OFF'); + } + final schemaVersion = + database.select('PRAGMA schema_version').single.values.first as int; + database.execute('PRAGMA schema_version = ${schemaVersion + 1}'); +} + Future<({AppDatabase database, Directory directory})> _openSchema5Fixture({ void Function(sqlite3.Database raw)? prepare, }) async { diff --git a/test/demo/demo_profile_test.dart b/test/demo/demo_profile_test.dart index bbd22184..6948847d 100644 --- a/test/demo/demo_profile_test.dart +++ b/test/demo/demo_profile_test.dart @@ -140,6 +140,7 @@ const _demoConfig = BuildConfig( ); const _feedback = FeedbackSubmission( + platform: 'linux', submissionId: 'demo-submission', appVersion: '1.0.0', buildNumber: '1', diff --git a/test/features/accounts/data/accounts_repository_test.dart b/test/features/accounts/data/accounts_repository_test.dart index a429e285..a94cb35d 100644 --- a/test/features/accounts/data/accounts_repository_test.dart +++ b/test/features/accounts/data/accounts_repository_test.dart @@ -1,5 +1,9 @@ import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/core/secrets/secret_store.dart'; +import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -54,4 +58,81 @@ void main() { expect(account.selectorLabel, 'Google'); }); + + test( + 'subscription predicates separate auth, task, schedule, and sync use', + () { + const account = AccountEntity( + id: 'webcal-account-sub', + provider: BusyProvider.webCal, + authority: 'https://calendar.example.test', + providerAccountId: 'fingerprint', + credentialKind: CredentialKind.webCalSubscription, + authState: accountAuthStateSignedIn, + displayName: 'Subscription', + tasksEnabled: false, + ); + + expect(account.isSubscription, isTrue); + expect(account.isAuthenticationAccount, isFalse); + expect(account.isTaskCapable, isFalse); + expect(account.isScheduleVisible, isTrue); + expect(account.isSyncEligible, isTrue); + }, + ); + + test( + 'subscription is sync-visible but excluded from auth account queries', + () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'webcal-account-sub', + provider: 'webcal', + authority: 'https://calendar.example.test', + providerAccountId: 'fingerprint', + credentialKind: 'webcal_subscription', + authState: const Value(accountAuthStateSignedIn), + calendarsEnabled: const Value(true), + tasksEnabled: const Value(false), + grantedScopes: const Value(''), + createdAtUtc: '2026-08-29T00:00:00.000Z', + updatedAtUtc: '2026-08-29T00:00:00.000Z', + ), + ); + final repository = AccountsRepository(database: database); + + expect( + (await repository.watchAccounts().first).single.isSubscription, + isTrue, + ); + expect(await repository.watchVisibleAccounts().first, isEmpty); + expect(await repository.listVisibleAccounts(), isEmpty); + expect(await repository.listSignedInAccounts(), isEmpty); + expect( + (await repository.listSyncEligibleAccounts()).single.provider, + BusyProvider.webCal, + ); + }, + ); + + test('rejects a credential kind that does not match the provider', () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + final repository = AccountsRepository(database: database); + + await expectLater( + repository.upsertSignedInAccount( + id: 'invalid', + provider: BusyProvider.google, + grantedScopes: '', + credentialKind: CredentialKind.webCalSubscription, + ), + throwsArgumentError, + ); + expect(await database.select(database.accounts).get(), isEmpty); + }); } diff --git a/test/features/accounts/domain/account_collection_creation_capabilities_test.dart b/test/features/accounts/domain/account_collection_creation_capabilities_test.dart new file mode 100644 index 00000000..f0b1d295 --- /dev/null +++ b/test/features/accounts/domain/account_collection_creation_capabilities_test.dart @@ -0,0 +1,133 @@ +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/accounts/domain/account_collection_creation_capabilities.dart'; +import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('provider matrix resolves distinct collection creation modes', () { + expect( + accountCollectionCreationModes(BusyProvider.google), + _hasModes( + CalendarCollectionCreationMode.cloudPendingOperation, + TaskListCreationMode.cloudPendingOperation, + ), + ); + expect( + accountCollectionCreationModes(BusyProvider.microsoft), + _hasModes( + CalendarCollectionCreationMode.cloudPendingOperation, + TaskListCreationMode.cloudPendingOperation, + ), + ); + expect( + accountCollectionCreationModes(BusyProvider.nextcloud), + _hasModes( + CalendarCollectionCreationMode.nextcloudDav, + TaskListCreationMode.nextcloudDav, + ), + ); + for (final provider in [BusyProvider.appleICloud, BusyProvider.webCal]) { + expect( + accountCollectionCreationModes(provider), + _hasModes( + CalendarCollectionCreationMode.unavailable, + TaskListCreationMode.unavailable, + ), + ); + } + }); + + test('account service switches omit their corresponding actions', () { + final withoutCalendars = _resolve( + _account(BusyProvider.google, calendarsEnabled: false), + ); + final withoutTasks = _resolve( + _account(BusyProvider.microsoft, tasksEnabled: false), + ); + + expect( + withoutCalendars.calendarMode, + CalendarCollectionCreationMode.unavailable, + ); + expect(withoutCalendars.supportsTaskListCreation, isTrue); + expect(withoutTasks.supportsCalendarCreation, isTrue); + expect(withoutTasks.taskListMode, TaskListCreationMode.unavailable); + }); + + test('reconnect and disconnected states disable supported entries', () { + for (final authState in [ + accountAuthStateReauthRequired, + accountAuthStateTemporarilyUnavailable, + ]) { + final capabilities = _resolve( + _account(BusyProvider.google, authState: authState), + ); + expect(capabilities.supportsCalendarCreation, isTrue); + expect(capabilities.supportsTaskListCreation, isTrue); + expect(capabilities.calendarActionEnabled, isFalse); + expect(capabilities.taskListActionEnabled, isFalse); + } + }); + + test('offline behavior preserves cloud queueing and disables DAV', () { + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + final capabilities = _resolve( + _account(provider), + network: NetworkAvailability.offline, + ); + expect(capabilities.calendarActionEnabled, isTrue); + expect(capabilities.taskListActionEnabled, isTrue); + } + + final nextcloud = _resolve( + _account(BusyProvider.nextcloud), + network: NetworkAvailability.offline, + ); + expect(nextcloud.calendarMode, CalendarCollectionCreationMode.nextcloudDav); + expect(nextcloud.taskListMode, TaskListCreationMode.nextcloudDav); + expect(nextcloud.calendarActionEnabled, isFalse); + expect(nextcloud.taskListActionEnabled, isFalse); + }); + + test('running state disables only the matching creation action', () { + final capabilities = AccountCollectionCreationCapabilities.resolve( + account: _account(BusyProvider.google), + networkAvailability: NetworkAvailability.online, + calendarCreationRunning: true, + ); + + expect(capabilities.calendarActionEnabled, isFalse); + expect(capabilities.taskListActionEnabled, isTrue); + }); +} + +Matcher _hasModes( + CalendarCollectionCreationMode calendarMode, + TaskListCreationMode taskListMode, +) => isA() + .having((value) => value.calendarMode, 'calendarMode', calendarMode) + .having((value) => value.taskListMode, 'taskListMode', taskListMode); + +AccountCollectionCreationCapabilities _resolve( + AccountEntity account, { + NetworkAvailability network = NetworkAvailability.online, +}) => AccountCollectionCreationCapabilities.resolve( + account: account, + networkAvailability: network, +); + +AccountEntity _account( + BusyProvider provider, { + bool calendarsEnabled = true, + bool tasksEnabled = true, + String authState = accountAuthStateSignedIn, +}) => AccountEntity( + id: provider.storageValue, + provider: provider, + authority: 'https://example.test', + providerAccountId: 'account', + authState: authState, + calendarsEnabled: calendarsEnabled, + tasksEnabled: tasksEnabled, +); diff --git a/test/features/auth/presentation/auth_routing_test.dart b/test/features/auth/presentation/auth_routing_test.dart index 536c2ce6..a9e4b918 100644 --- a/test/features/auth/presentation/auth_routing_test.dart +++ b/test/features/auth/presentation/auth_routing_test.dart @@ -23,6 +23,7 @@ import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; import 'package:busymax/src/core/secrets/secret_store.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/platform/linux_header_bar_provider.dart'; import 'package:busymax/src/platform/native_dialog_service.dart'; import 'package:busymax/src/schedule/schedule_scope.dart'; import 'package:busymax/src/providers/busy_provider.dart'; @@ -657,7 +658,7 @@ Future _disposeApp(WidgetTester tester) async { Future _openSettings(WidgetTester tester) async { final schedule = find.byType(ScheduleWorkspace); expect(schedule, findsOneWidget); - GoRouter.of(tester.element(schedule)).go('/settings'); + GoRouter.of(tester.element(schedule)).go('/settings?page=accounts'); await tester.pumpAndSettle(); expect(find.byType(SettingsScreen), findsOneWidget); } @@ -716,7 +717,6 @@ Future _pumpApp( signedInSyncRunnerProvider.overrideWithValue( onSignedIn ?? (accountId, initial) async {}, ), - syncEngineProvider.overrideWithValue(null), linuxHeaderBarServiceProvider.overrideWith((ref) { final service = LinuxHeaderBarService(isLinux: false); ref.onDispose(service.dispose); diff --git a/test/features/calendar/data/calendar_collection_creation_service_test.dart b/test/features/calendar/data/calendar_collection_creation_service_test.dart new file mode 100644 index 00000000..67ce2865 --- /dev/null +++ b/test/features/calendar/data/calendar_collection_creation_service_test.dart @@ -0,0 +1,190 @@ +import 'package:busymax/src/dav/http/dav_http_transport.dart'; +import 'package:busymax/src/dav/mutation/dav_calendar_collection_mutation_service.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/calendar/data/calendar_collection_creation_service.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:drift/drift.dart' hide isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late AppDatabase database; + late _FakeDavCalendarClient davClient; + late List syncRequests; + + setUp(() { + database = AppDatabase(NativeDatabase.memory()); + davClient = _FakeDavCalendarClient(); + syncRequests = []; + }); + + tearDown(() => database.close()); + + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + test('${provider.displayName} preserves cloud pending creation', () async { + await _seedAccount(database, provider); + final result = await _service( + database, + davClient, + syncRequests, + ).createCalendar(accountId: 'account', title: 'Projects'); + + expect(result.outcome, CalendarCollectionCreationOutcome.queued); + expect(result.localSourceId, isNotNull); + final source = await database + .select(database.calendarSources) + .getSingle(); + expect(source.providerCalendarId, startsWith('local:')); + final operation = await database.select(database.pendingOps).getSingle(); + expect(operation.operationType, 'calendar.create'); + expect(operation.accountId, 'account'); + expect(syncRequests, ['account']); + expect(davClient.titles, isEmpty); + }); + } + + test( + 'Nextcloud uses DAV without a local source or pending operation', + () async { + await _seedAccount(database, BusyProvider.nextcloud); + final result = await _service( + database, + davClient, + syncRequests, + ).createCalendar(accountId: 'account', title: 'Nextcloud calendar'); + + expect(result.outcome, CalendarCollectionCreationOutcome.created); + expect(davClient.titles, ['Nextcloud calendar']); + expect(await database.select(database.calendarSources).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(syncRequests, isEmpty); + }, + ); + + test('maps a successful remote creation with pending refresh', () async { + await _seedAccount(database, BusyProvider.nextcloud); + davClient.result = const DavCalendarCollectionCreationResult( + DavCalendarCollectionCreationStatus.createdRefreshPending, + ); + + final result = await _service( + database, + davClient, + syncRequests, + ).createCalendar(accountId: 'account', title: 'Remote'); + + expect( + result.outcome, + CalendarCollectionCreationOutcome.createdRefreshPending, + ); + }); + + for (final provider in [BusyProvider.appleICloud, BusyProvider.webCal]) { + test('${provider.displayName} is rejected before local mutation', () async { + await _seedAccount(database, provider); + + await expectLater( + _service( + database, + davClient, + syncRequests, + ).createCalendar(accountId: 'account', title: 'Unsupported'), + throwsA(isA()), + ); + expect(await database.select(database.calendarSources).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(davClient.titles, isEmpty); + expect(syncRequests, isEmpty); + }); + } + + test('disabled calendar service rejects before local mutation', () async { + await _seedAccount(database, BusyProvider.google, calendarsEnabled: false); + + await expectLater( + _service( + database, + davClient, + syncRequests, + ).createCalendar(accountId: 'account', title: 'Disabled'), + throwsA(isA()), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + test('cloud repository defensively rejects Nextcloud', () async { + await _seedAccount(database, BusyProvider.nextcloud); + + await expectLater( + CalendarRepository( + database: database, + ).createLocalSource(accountId: 'account', summary: 'Wrong route'), + throwsA(isA()), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); +} + +CalendarCollectionCreationService _service( + AppDatabase database, + _FakeDavCalendarClient davClient, + List syncRequests, +) => CalendarCollectionCreationService( + accountsRepository: AccountsRepository(database: database), + calendarRepository: CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 8, 29, 12), + ), + davClientForAccount: (_) => davClient, + requestCloudSynchronization: syncRequests.add, +); + +Future _seedAccount( + AppDatabase database, + BusyProvider provider, { + bool calendarsEnabled = true, +}) async { + const now = '2026-08-29T12:00:00.000Z'; + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'account', + provider: provider.storageValue, + authority: 'https://example.test', + providerAccountId: 'identity', + credentialKind: provider == BusyProvider.webCal + ? 'webcal_subscription' + : provider == BusyProvider.nextcloud + ? 'nextcloud_app_password' + : provider == BusyProvider.appleICloud + ? 'apple_app_specific_password' + : 'oauth', + authState: const Value(accountAuthStateSignedIn), + calendarsEnabled: Value(calendarsEnabled), + tasksEnabled: Value(provider != BusyProvider.webCal), + createdAtUtc: now, + updatedAtUtc: now, + ), + ); +} + +final class _FakeDavCalendarClient + implements DavCalendarCollectionMutationClient { + final titles = []; + DavCalendarCollectionCreationResult result = + const DavCalendarCollectionCreationResult( + DavCalendarCollectionCreationStatus.created, + ); + + @override + Future createEventCalendar( + String title, { + DavCancellationToken? cancellationToken, + }) async { + titles.add(title); + return result; + } +} diff --git a/test/features/calendar/data/calendar_repository_test.dart b/test/features/calendar/data/calendar_repository_test.dart index ab750585..9a10a41d 100644 --- a/test/features/calendar/data/calendar_repository_test.dart +++ b/test/features/calendar/data/calendar_repository_test.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:busymax/src/calendar_providers/calendar_colors.dart'; +import 'package:busymax/src/calendar_providers/calendar_create_identity.dart'; import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; import 'package:busymax/src/db/app_database.dart'; @@ -32,6 +34,7 @@ void main() { authority: 'https://accounts.google.com', providerAccountId: 'g', credentialKind: 'oauth', + email: const Value('me@example.com'), authState: const Value('signed_in'), grantedScopes: const Value(''), createdAtUtc: '2026-06-08T00:00:00.000Z', @@ -140,6 +143,225 @@ void main() { expect(sources.single.allowedConferenceSolutions, ['hangoutsMeet']); }); + test( + 'calendar creation is optimistic and queued for offline replay', + () async { + final sourceId = await repository.createLocalSource( + accountId: 'google:g', + summary: 'Project', + ); + + final source = await database + .select(database.calendarSources) + .getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + expect(source.id, sourceId); + expect(source.providerCalendarId, startsWith('local:')); + expect(source.summary, 'Project'); + expect(operation.operationType, 'calendar.create'); + expect(operation.calendarSourceId, sourceId); + expect(operation.providerCalendarId, source.providerCalendarId); + expect(jsonDecode(operation.requestJson), {'summary': 'Project'}); + final entity = (await repository.watchSourcesForAccounts(const [ + 'google:g', + ]).first).single; + expect(entity.pendingCreate, isTrue); + expect(entity.capabilities.renameMode, CalendarRenameMode.global); + expect(entity.capabilities.canRemoveCalendar, isTrue); + }, + ); + + test('event creation waits for its locally created calendar', () async { + final sourceId = await repository.createLocalSource( + accountId: 'google:g', + summary: 'Project', + ); + final source = await database.select(database.calendarSources).getSingle(); + final calendarCreate = await database + .select(database.pendingOps) + .getSingle(); + + await repository.createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'google:g', + sourceId: sourceId, + providerCalendarId: source.providerCalendarId, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith(title: 'Planning'), + ); + + final operations = await database.select(database.pendingOps).get(); + final eventCreate = operations.singleWhere( + (operation) => operation.operationType == 'event.create', + ); + expect(eventCreate.dependsOnOpId, calendarCreate.id); + }); + + test('renaming an unsynced calendar updates its create operation', () async { + final sourceId = await repository.createLocalSource( + accountId: 'google:g', + summary: 'Initial name', + ); + + await repository.renameLocalSource(sourceId, 'Final name'); + + final source = await database.select(database.calendarSources).getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + expect(source.summary, 'Final name'); + expect(operation.operationType, 'calendar.create'); + expect(jsonDecode(operation.requestJson), {'summary': 'Final name'}); + }); + + test('calendar color is projected locally and queued by provider', () async { + await _upsertSource(repository); + const choice = CalendarColorChoice( + providerValue: '#3584e4', + backgroundColor: '#3584e4', + ); + + await repository.setSourceColor(_sourceId, choice); + + final source = await database.select(database.calendarSources).getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + expect(source.backgroundColor, '#3584e4'); + expect(source.foregroundColor, '#000000'); + expect(source.colorId, null); + expect(operation.operationType, 'calendar.patch'); + expect(jsonDecode(operation.requestJson), { + 'backgroundColor': '#3584e4', + 'foregroundColor': '#000000', + calendarMutationScopeKey: calendarMutationScopePersonal, + calendarPatchPreviousValuesKey: { + 'backgroundColor': null, + 'foregroundColor': null, + 'colorId': null, + }, + }); + }); + + test('deleting an unsynced calendar cancels its pending work', () async { + final sourceId = await repository.createLocalSource( + accountId: 'google:g', + summary: 'Temporary', + ); + + await repository.deleteLocalSource(sourceId); + + expect(await database.select(database.calendarSources).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + test( + 'calendar removal waits for pending event mutations without losing them', + () async { + await _upsertSource(repository); + await repository.createLocalEvent( + _newEventDraft().copyWith(title: 'Pending event'), + ); + final pendingBefore = await database.select(database.pendingOps).get(); + + await expectLater( + repository.deleteLocalSource(_sourceId), + throwsA( + isA() + .having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.deleteCalendar, + ) + .having( + (error) => error.reason, + 'reason', + CalendarMutationDenialReason.pendingChanges, + ), + ), + ); + + final source = await database + .select(database.calendarSources) + .getSingle(); + final event = await database.select(database.calendarEvents).getSingle(); + final pendingAfter = await database.select(database.pendingOps).get(); + expect(source.isDeleted, isFalse); + expect(source.hidden, isFalse); + expect(event.syncStatus, 'pending'); + expect( + pendingAfter.map((operation) => operation.id), + pendingBefore.map((operation) => operation.id), + ); + expect(pendingAfter.single.operationType, 'event.create'); + }, + ); + + test('DAV calendar management is rejected before local mutation', () async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'nextcloud:n', + provider: 'nextcloud', + authority: 'https://cloud.example.test', + providerAccountId: 'n', + credentialKind: 'nextcloud_app_password', + authState: const Value('signed_in'), + grantedScopes: const Value(''), + createdAtUtc: '2026-06-08T00:00:00.000Z', + updatedAtUtc: '2026-06-08T00:00:00.000Z', + ), + ); + await expectLater( + repository.createLocalSource( + accountId: 'nextcloud:n', + summary: 'New calendar', + ), + throwsA( + isA().having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.createCalendar, + ), + ), + ); + await repository.upsertSource( + accountId: 'nextcloud:n', + source: const CalendarSourceDto( + provider: BusyProvider.nextcloud, + providerCalendarId: '/calendars/n/work/', + summary: 'Work', + ), + ); + final source = await database.select(database.calendarSources).getSingle(); + + await expectLater( + repository.renameLocalSource(source.id, 'Renamed'), + throwsA( + isA().having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.renameCalendar, + ), + ), + ); + await expectLater( + repository.deleteLocalSource(source.id), + throwsA( + isA().having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.deleteCalendar, + ), + ), + ); + + final unchanged = await database + .select(database.calendarSources) + .getSingle(); + expect(unchanged.summary, 'Work'); + expect(unchanged.isDeleted, isFalse); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + test( 'event queue preserves guest delivery and Meet creation intent', () async { @@ -164,6 +386,10 @@ void main() { request[calendarEventGuestUpdatePolicyKey], CalendarGuestUpdatePolicy.doNotSend.name, ); + expect( + request[calendarEventGoogleCreateIdKey], + googleCalendarCreateEventId(operation.id), + ); expect(createRequest['requestId'], isNotEmpty); expect(createRequest['conferenceSolutionKey'], {'type': 'hangoutsMeet'}); expect(jsonDecode(event.conferenceJson!), conference); @@ -198,6 +424,8 @@ void main() { ); final event = await database.select(database.calendarEvents).getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + final request = jsonDecode(operation.requestJson) as Map; expect(jsonDecode(event.rawJson!), { 'importance': 'high', 'responseRequested': false, @@ -205,6 +433,10 @@ void main() { 'allowNewTimeProposals': false, 'isOrganizer': true, }); + expect( + request[calendarEventMicrosoftTransactionIdKey], + microsoftCalendarCreateTransactionId(operation.id), + ); }); test( @@ -348,7 +580,12 @@ void main() { expect(draft.allowNewTimeProposals, isFalse); expect(draft.isOrganizer, isFalse); - await repository.updateLocalEvent(draft.copyWith(title: 'Updated')); + await repository.updateLocalEvent( + draft.copyWith( + title: 'Updated', + recurringMutationScope: RecurringEventMutationScope.singleOccurrence, + ), + ); final after = await (database.select( database.calendarEvents, @@ -358,6 +595,368 @@ void main() { }, ); + test( + 'Google invitation edit permissions are enforced before mutation', + () async { + await _upsertSource(repository); + await repository.upsertEvent( + accountId: 'google:g', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'invitation', + title: 'Invitation', + startDateTime: '2026-06-08T09:00:00-07:00', + endDateTime: '2026-06-08T10:00:00-07:00', + organizerJson: {'email': 'owner@example.com', 'self': false}, + attendeesJson: [ + { + 'email': 'me@example.com', + 'self': true, + 'responseStatus': 'needsAction', + }, + ], + rawJson: {'id': 'invitation'}, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'invitation', + ); + final detail = await repository.loadEventDetail(eventId); + + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + detail!, + ).copyWith(title: 'Unauthorized edit'), + ), + throwsA(isA()), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect((await repository.loadEventDetail(eventId))!.title, 'Invitation'); + + await repository.respondToLocalEvent( + eventId, + CalendarInvitationResponse.accept, + ); + final responseOp = await database.select(database.pendingOps).getSingle(); + expect(responseOp.operationType, 'event.respond'); + }, + ); + + test( + 'Google guest event editing does not grant attendee management', + () async { + await _upsertSource(repository); + await repository.upsertEvent( + accountId: 'google:g', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'guest-editable', + title: 'Shared planning', + startDateTime: '2026-06-08T09:00:00-07:00', + endDateTime: '2026-06-08T10:00:00-07:00', + organizerJson: {'email': 'owner@example.com', 'self': false}, + attendeesJson: [ + {'email': 'me@example.com', 'self': true}, + ], + rawJson: { + 'id': 'guest-editable', + 'guestsCanModify': true, + 'guestsCanInviteOthers': false, + }, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'guest-editable', + ); + final detail = await repository.loadEventDetail(eventId); + final draft = EventEditorDraft.fromEventDetail(detail!); + + expect(detail.guestsCanInviteOthers, isFalse); + expect(draft.canManageAttendees, isFalse); + await expectLater( + repository.updateLocalEvent( + draft.copyWith( + attendees: const [ + EventAttendeeDraft(email: 'me@example.com', self: true), + EventAttendeeDraft(email: 'new@example.com'), + ], + ), + ), + throwsA(isA()), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + + await repository.updateLocalEvent( + draft.copyWith(title: 'Allowed title edit'), + ); + expect( + (await repository.loadEventDetail(eventId))!.title, + 'Allowed title edit', + ); + }, + ); + + test('title-only recurring Google edit preserves explicit UTC', () async { + await _upsertSource(repository); + const recurrence = ['RRULE:FREQ=WEEKLY;BYDAY=MO']; + await repository.upsertEvent( + accountId: 'google:g', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'utc-series', + title: 'UTC series', + startDateTime: '2026-06-08T09:00:00Z', + startTimeZone: 'UTC', + endDateTime: '2026-06-08T10:00:00Z', + endTimeZone: 'UTC', + recurrenceJson: recurrence, + organizerJson: {'self': true}, + updatedAtServer: '2026-06-01T00:00:00Z', + rawJson: { + 'id': 'utc-series', + 'summary': 'UTC series', + 'start': {'dateTime': '2026-06-08T09:00:00Z', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-06-08T10:00:00Z', 'timeZone': 'UTC'}, + 'recurrence': recurrence, + 'organizer': {'self': true}, + 'updated': '2026-06-01T00:00:00Z', + }, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'utc-series', + ); + final detail = await repository.loadEventDetail(eventId); + + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith(title: 'Renamed'), + ); + + final event = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + final request = jsonDecode(operation.requestJson) as Map; + expect(request, { + 'title': 'Renamed', + calendarEventGuestUpdatePolicyKey: 'send', + }); + expect(event.startDateTime, '2026-06-08T09:00:00Z'); + expect(event.endDateTime, '2026-06-08T10:00:00Z'); + expect(event.startTimeZone, 'UTC'); + expect(event.endTimeZone, 'UTC'); + expect(event.recurrenceJson, jsonEncode(recurrence)); + }); + + test('title-only Google edit preserves offset-only timestamps', () async { + await _upsertSource(repository); + await repository.upsertEvent( + accountId: 'google:g', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'offset-event', + title: 'Offset event', + startDateTime: '2026-06-08T09:00:00+02:00', + endDateTime: '2026-06-08T10:00:00+02:00', + organizerJson: {'self': true}, + updatedAtServer: '2026-06-01T00:00:00Z', + rawJson: { + 'id': 'offset-event', + 'summary': 'Offset event', + 'start': {'dateTime': '2026-06-08T09:00:00+02:00'}, + 'end': {'dateTime': '2026-06-08T10:00:00+02:00'}, + 'organizer': {'self': true}, + 'updated': '2026-06-01T00:00:00Z', + }, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'offset-event', + ); + final detail = await repository.loadEventDetail(eventId); + + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith(title: 'Renamed'), + ); + + final event = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + final request = jsonDecode(operation.requestJson) as Map; + expect(request.keys, {'title', calendarEventGuestUpdatePolicyKey}); + expect(event.startDateTime, '2026-06-08T09:00:00+02:00'); + expect(event.endDateTime, '2026-06-08T10:00:00+02:00'); + expect(event.startTimeZone, equals(null)); + expect(event.endTimeZone, equals(null)); + }); + + test('title-only Microsoft edit omits structured location', () async { + await repository.upsertSource( + accountId: 'google:g', + source: const CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'microsoft-calendar', + summary: 'Outlook', + ), + ); + const raw = { + 'id': 'structured-location', + 'subject': 'Site visit', + 'location': { + 'displayName': 'Main office', + 'locationType': 'businessAddress', + 'address': { + 'street': '1 Example Street', + 'city': 'Vancouver', + 'countryOrRegion': 'Canada', + }, + 'coordinates': {'latitude': 49.2827, 'longitude': -123.1207}, + }, + 'start': { + 'dateTime': '2026-06-08T09:00:00', + 'timeZone': 'Pacific Standard Time', + }, + 'end': { + 'dateTime': '2026-06-08T10:00:00', + 'timeZone': 'Pacific Standard Time', + }, + 'updated': '2026-06-01T00:00:00Z', + }; + await repository.upsertEvent( + accountId: 'google:g', + event: const CalendarEventDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'microsoft-calendar', + providerEventId: 'structured-location', + title: 'Site visit', + location: 'Main office', + startDateTime: '2026-06-08T09:00:00', + startTimeZone: 'Pacific Standard Time', + endDateTime: '2026-06-08T10:00:00', + endTimeZone: 'Pacific Standard Time', + updatedAtServer: '2026-06-01T00:00:00Z', + rawJson: raw, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.microsoft, + providerCalendarId: 'microsoft-calendar', + providerEventId: 'structured-location', + ); + final detail = await repository.loadEventDetail(eventId); + + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith(title: 'Renamed'), + ); + + final event = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + final request = jsonDecode(operation.requestJson) as Map; + expect(request, { + 'title': 'Renamed', + calendarEventGuestUpdatePolicyKey: 'send', + }); + expect(event.location, 'Main office'); + expect(jsonDecode(event.rawJson!), raw); + }); + + test( + 'entire-series edit targets the master and projects every occurrence', + () async { + await _upsertSource(repository); + final eventIds = []; + for (final day in [1, 8, 15]) { + eventIds.add(await _upsertGoogleOccurrence(repository, day: day)); + } + final detail = await repository.loadEventDetail(eventIds[1]); + + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith( + title: 'Renamed series', + recurringMutationScope: RecurringEventMutationScope.entireSeries, + ), + ); + + final rows = + await (database.select(database.calendarEvents)..orderBy([ + (row) => OrderingTerm.asc(row.providerOriginalStartKey), + ])) + .get(); + expect(rows.map((row) => row.title), everyElement('Renamed series')); + expect(rows.map((row) => row.syncStatus), everyElement('pending')); + final operation = await database.select(database.pendingOps).getSingle(); + final request = jsonDecode(operation.requestJson) as Map; + expect(request, { + calendarEventGuestUpdatePolicyKey: 'send', + 'title': 'Renamed series', + calendarEventRecurringScopeKey: 'entireSeries', + calendarEventTargetProviderIdKey: 'series-master', + calendarEventOriginalStartKey: '2026-06-08T09:00:00.000Z', + calendarEventOriginalEndKey: '2026-06-08T10:00:00.000Z', + }); + expect(operation.baselineUpdatedUtc, equals(null)); + expect(operation.baselineRawJson, equals(null)); + }, + ); + + test( + 'this-and-following edit projects only the target and later rows', + () async { + await _upsertSource(repository); + final eventIds = []; + for (final day in [1, 8, 15]) { + eventIds.add(await _upsertGoogleOccurrence(repository, day: day)); + } + final detail = await repository.loadEventDetail(eventIds[1]); + + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith( + location: 'New room', + recurringMutationScope: RecurringEventMutationScope.thisAndFuture, + ), + ); + + final rows = + await (database.select(database.calendarEvents)..orderBy([ + (row) => OrderingTerm.asc(row.providerOriginalStartKey), + ])) + .get(); + expect(rows.map((row) => row.location), [null, 'New room', 'New room']); + expect(rows.map((row) => row.syncStatus), [ + 'synced', + 'pending', + 'pending', + ]); + final operation = await database.select(database.pendingOps).getSingle(); + final request = jsonDecode(operation.requestJson) as Map; + expect(request[calendarEventRecurringScopeKey], 'thisAndFuture'); + expect(request[calendarEventTargetProviderIdKey], 'series-master'); + expect(request['location'], 'New room'); + expect(request, isNot(contains('start'))); + }, + ); + test('local attendee state retains provider response metadata', () async { await _upsertSource(repository); await repository.upsertEvent( @@ -500,6 +1099,36 @@ void main() { expect(source.isDeleted, isTrue); }); + test( + 'provider upsert restores a source after removal work is gone', + () async { + await _upsertSource(repository); + await repository.deleteLocalSource(_sourceId); + final operation = await database.select(database.pendingOps).getSingle(); + expect(jsonDecode(operation.requestJson), { + calendarRemovalPreviousHiddenKey: false, + }); + await database.pendingOpsDao.deleteOp(operation.id); + + await repository.upsertSource( + accountId: 'google:g', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + summary: 'Still returned by provider', + hidden: false, + isDeleted: false, + ), + ); + + final source = await database + .select(database.calendarSources) + .getSingle(); + expect(source.hidden, isFalse); + expect(source.isDeleted, isFalse); + }, + ); + test('source stream removes locally tombstoned calendars', () async { await _upsertSource(repository); expect( @@ -515,6 +1144,79 @@ void main() { ); }); + test('shared Google calendar removal queues CalendarList deletion', () async { + await repository.upsertSource( + accountId: 'google:g', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'shared@example.com', + summary: 'Shared', + readOnly: true, + dataOwner: 'owner@example.com', + ), + ); + const sourceId = 'google:g|google|shared@example.com'; + + await repository.deleteLocalSource(sourceId); + + final operation = await database.select(database.pendingOps).getSingle(); + expect(operation.operation, 'remove'); + expect(operation.operationType, 'calendar.remove'); + expect(operation.providerCalendarId, 'shared@example.com'); + }); + + test( + 'shared read-only Google calendar uses personal name and color updates', + () async { + await repository.upsertSource( + accountId: 'google:g', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'shared@example.com', + summary: 'Shared', + readOnly: true, + dataOwner: 'owner@example.com', + ), + ); + const sourceId = 'google:g|google|shared@example.com'; + + await repository.renameLocalSource(sourceId, 'My shared calendar'); + await repository.setSourceColor( + sourceId, + calendarColorChoices(BusyProvider.google).first, + ); + + final operation = await database.select(database.pendingOps).getSingle(); + final request = jsonDecode(operation.requestJson) as Map; + expect(request['summary'], 'My shared calendar'); + expect(request[calendarMutationScopeKey], calendarMutationScopePersonal); + expect(request['backgroundColor'], isA()); + }, + ); + + test('Microsoft calendar deletion requires isRemovable', () async { + await repository.upsertSource( + accountId: 'google:g', + source: const CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'not-removable', + summary: 'Editable shared calendar', + readOnly: false, + isRemovable: false, + ), + ); + + await expectLater( + repository.deleteLocalSource('google:g|microsoft|not-removable'), + throwsA(isA()), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect( + (await database.select(database.calendarSources).getSingle()).isDeleted, + isFalse, + ); + }); + test('provider hidden state can return to visible', () async { await repository.upsertSource( accountId: 'google:g', @@ -664,10 +1366,54 @@ Future _upsertSource(CalendarRepository repository) { provider: BusyProvider.google, providerCalendarId: 'calendar-1', summary: 'Calendar', + dataOwner: 'me@example.com', ), ); } +Future _upsertGoogleOccurrence( + CalendarRepository repository, { + required int day, +}) async { + final date = day.toString().padLeft(2, '0'); + final start = '2026-06-${date}T09:00:00.000Z'; + final end = '2026-06-${date}T10:00:00.000Z'; + final providerEventId = 'occurrence-$date'; + await repository.upsertEvent( + accountId: 'google:g', + event: CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: providerEventId, + providerRecurringEventId: 'series-master', + providerOriginalStartKey: start, + title: 'Weekly planning', + organizerJson: const {'self': true}, + startDateTime: start, + startTimeZone: 'UTC', + endDateTime: end, + endTimeZone: 'UTC', + updatedAtServer: '2026-05-30T00:00:00.000Z', + rawJson: { + 'id': providerEventId, + 'summary': 'Weekly planning', + 'recurringEventId': 'series-master', + 'originalStartTime': {'dateTime': start}, + 'start': {'dateTime': start, 'timeZone': 'UTC'}, + 'end': {'dateTime': end, 'timeZone': 'UTC'}, + 'updated': '2026-05-30T00:00:00.000Z', + }, + ), + ); + return CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: providerEventId, + providerOriginalStartKey: start, + ); +} + EventEditorDraft _newEventDraft() { return EventEditorDraft.newEvent( accountId: 'google:g', @@ -752,6 +1498,10 @@ Future _seedScheduledEvent( }, ), ); + await database.delete(database.pendingOps).go(); + await database + .update(database.calendarEvents) + .write(const CalendarEventsCompanion(syncStatus: Value('synced'))); expect( await database.select(database.notificationSchedule).get(), hasLength(1), diff --git a/test/features/calendar/domain/calendar_management_capabilities_test.dart b/test/features/calendar/domain/calendar_management_capabilities_test.dart new file mode 100644 index 00000000..080f92cc --- /dev/null +++ b/test/features/calendar/domain/calendar_management_capabilities_test.dart @@ -0,0 +1,171 @@ +import 'package:busymax/src/calendar_providers/calendar_provider_capabilities.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Google and Microsoft expose cloud calendar management', () { + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + final capabilities = calendarManagementCapabilities(provider); + expect(capabilities.supportsCreate, isTrue); + expect(capabilities.supportsRename, isTrue); + expect(capabilities.supportsDelete, isTrue); + expect(capabilities.supportsColor, isTrue); + } + }); + + test('DAV calendar collection management is disabled', () { + for (final provider in [BusyProvider.appleICloud, BusyProvider.nextcloud]) { + final capabilities = calendarManagementCapabilities(provider); + expect(capabilities.supportsCreate, isFalse); + expect(capabilities.supportsRename, isFalse); + expect(capabilities.supportsDelete, isFalse); + expect(capabilities.supportsColor, isFalse); + } + }); + + test( + 'DAV source actions remain disabled even when the source is writable', + () { + const source = CalendarSourceEntity( + id: 'dav-calendar', + accountId: 'nextcloud-account', + provider: BusyProvider.nextcloud, + providerCalendarId: '/calendars/user/work/', + summary: 'Work', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ); + + expect(source.capabilities.canCreateEvents, isTrue); + expect(source.capabilities.canRenameCalendar, isFalse); + expect(source.capabilities.canDeleteCalendar, isFalse); + expect(source.capabilities.canChangeCalendarColor, isFalse); + }, + ); + + test('primary cloud calendar cannot be deleted', () { + const source = CalendarSourceEntity( + id: 'primary-calendar', + accountId: 'google-account', + provider: BusyProvider.google, + providerCalendarId: 'primary', + summary: 'Primary', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + primaryCalendar: true, + ); + + expect(source.capabilities.canRenameCalendar, isTrue); + expect(source.capabilities.canDeleteCalendar, isFalse); + expect(source.capabilities.canChangeCalendarColor, isTrue); + }); + + test('owned Google secondary calendar permits global rename and delete', () { + const source = CalendarSourceEntity( + id: 'owned-calendar', + accountId: 'google-account', + provider: BusyProvider.google, + providerCalendarId: 'owned@example.com', + summary: 'Owned', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + dataOwner: 'owner@example.com', + authenticatedAccountEmail: 'OWNER@example.com', + ); + + expect(source.capabilities.renameMode, CalendarRenameMode.global); + expect(source.capabilities.removalMode, CalendarRemovalMode.delete); + expect(source.capabilities.canDeleteCalendar, isTrue); + }); + + test( + 'shared read-only Google calendar permits personal name, color, and removal', + () { + const source = CalendarSourceEntity( + id: 'shared-calendar', + accountId: 'google-account', + provider: BusyProvider.google, + providerCalendarId: 'shared@example.com', + summary: 'Shared', + selected: true, + hidden: false, + readOnly: true, + isDeleted: false, + dataOwner: 'other@example.com', + authenticatedAccountEmail: 'me@example.com', + ); + + expect(source.capabilities.canCreateEvents, isFalse); + expect(source.capabilities.renameMode, CalendarRenameMode.personal); + expect( + source.capabilities.removalMode, + CalendarRemovalMode.removeFromList, + ); + expect(source.capabilities.canDeleteCalendar, isFalse); + expect(source.capabilities.canRemoveCalendar, isTrue); + expect(source.capabilities.canChangeCalendarColor, isTrue); + }, + ); + + test('Microsoft deletion follows isRemovable rather than canEdit', () { + const removableReadOnly = CalendarSourceEntity( + id: 'shared-outlook', + accountId: 'microsoft-account', + provider: BusyProvider.microsoft, + providerCalendarId: 'shared', + summary: 'Shared', + selected: true, + hidden: false, + readOnly: true, + isDeleted: false, + isRemovable: true, + ); + const editableNotRemovable = CalendarSourceEntity( + id: 'owned-outlook', + accountId: 'microsoft-account', + provider: BusyProvider.microsoft, + providerCalendarId: 'owned', + summary: 'Owned', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + isRemovable: false, + ); + + expect( + removableReadOnly.capabilities.removalMode, + CalendarRemovalMode.delete, + ); + expect(editableNotRemovable.capabilities.canRemoveCalendar, isFalse); + }); + + test('pending cloud calendar creation can be renamed and cancelled', () { + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + final source = CalendarSourceEntity( + id: '${provider.storageValue}-pending', + accountId: '${provider.storageValue}-account', + provider: provider, + providerCalendarId: 'local:pending', + summary: 'New calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + pendingCreate: true, + ); + + expect(source.capabilities.renameMode, CalendarRenameMode.global); + expect(source.capabilities.removalMode, CalendarRemovalMode.delete); + expect(source.capabilities.canRenameCalendar, isTrue); + expect(source.capabilities.canRemoveCalendar, isTrue); + } + }); +} diff --git a/test/features/calendar/domain/event_move_policy_test.dart b/test/features/calendar/domain/event_move_policy_test.dart new file mode 100644 index 00000000..55b17739 --- /dev/null +++ b/test/features/calendar/domain/event_move_policy_test.dart @@ -0,0 +1,96 @@ +import 'package:busymax/src/features/calendar/domain/event_move_policy.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() { + test('Google uses native movement only within one account', () { + expect( + _strategy( + sourceProvider: BusyProvider.google, + destinationProvider: BusyProvider.google, + ), + CalendarEventMoveStrategy.googleNative, + ); + expect( + _strategy( + sourceProvider: BusyProvider.google, + destinationProvider: BusyProvider.google, + destinationAccountId: 'other-account', + ), + CalendarEventMoveStrategy.copyThenDelete, + ); + }); + + test('Google special events and individual occurrences are copied', () { + expect( + _strategy( + sourceProvider: BusyProvider.google, + destinationProvider: BusyProvider.google, + eventType: 'birthday', + ), + CalendarEventMoveStrategy.copyThenDelete, + ); + expect( + _strategy( + sourceProvider: BusyProvider.google, + destinationProvider: BusyProvider.google, + recurring: true, + recurringScope: RecurringEventMutationScope.singleOccurrence, + ), + CalendarEventMoveStrategy.copyThenDelete, + ); + }); + + test('same-account CalDAV movement uses the DAV resource operation', () { + for (final provider in [BusyProvider.nextcloud, BusyProvider.appleICloud]) { + expect( + _strategy( + sourceProvider: provider, + destinationProvider: provider, + sourceDavCollectionId: 'source-collection', + destinationDavCollectionId: 'destination-collection', + ), + CalendarEventMoveStrategy.davNative, + ); + } + }); + + test('Microsoft movement uses explicit copy then delete', () { + expect( + _strategy( + sourceProvider: BusyProvider.microsoft, + destinationProvider: BusyProvider.microsoft, + ), + CalendarEventMoveStrategy.copyThenDelete, + ); + }); +} + +CalendarEventMoveStrategy _strategy({ + required BusyProvider sourceProvider, + required BusyProvider destinationProvider, + String sourceAccountId = 'account', + String destinationAccountId = 'account', + String? sourceDavCollectionId, + String? destinationDavCollectionId, + String? eventType = 'default', + bool recurring = false, + RecurringEventMutationScope? recurringScope, +}) { + return calendarEventMoveStrategy( + sourceAccountId: sourceAccountId, + sourceId: 'source', + sourceProviderCalendarId: 'source-calendar', + sourceProvider: sourceProvider, + sourceDavCollectionId: sourceDavCollectionId, + destinationAccountId: destinationAccountId, + destinationId: 'destination', + destinationProviderCalendarId: 'destination-calendar', + destinationProvider: destinationProvider, + destinationDavCollectionId: destinationDavCollectionId, + eventType: eventType, + recurring: recurring, + recurringScope: recurringScope, + ); +} diff --git a/test/features/calendar/presentation/calendar_color_dialog_test.dart b/test/features/calendar/presentation/calendar_color_dialog_test.dart new file mode 100644 index 00000000..e6e6363b --- /dev/null +++ b/test/features/calendar/presentation/calendar_color_dialog_test.dart @@ -0,0 +1,49 @@ +import 'package:busymax/src/calendar_providers/calendar_colors.dart'; +import 'package:busymax/src/features/calendar/presentation/calendar_color_dialog.dart'; +import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../test_localized_app.dart'; + +void main() { + testWidgets('Google calendar color dialog returns the selected RGB color', ( + tester, + ) async { + final headerBarService = LinuxHeaderBarService(isLinux: false); + addTearDown(headerBarService.dispose); + Future? result; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () { + result = showCalendarColorDialog( + context, + provider: BusyProvider.google, + currentBackgroundColor: '#3584e4', + currentColorId: null, + headerBarService: headerBarService, + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(find.text('Calendar color'), findsOneWidget); + + await tester.tap(find.byTooltip('Color 2')); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + final choice = await result; + expect(choice!.backgroundColor, '#1c71d8'); + }); +} diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 21a46da2..78fd064f 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -1,14 +1,15 @@ import 'dart:io'; -import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor_draft.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/features/recurrence/presentation/recurrence_editor.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; -import 'package:busymax/src/microsoft_calendar/microsoft_calendar_mapper.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'; @@ -710,173 +711,241 @@ void main() { expect(attendee.optional, isTrue); }); - for (final recurrenceCase in _microsoftRecurrenceCases.entries) { - testWidgets( - 'Microsoft ${recurrenceCase.key} recurrence emits required Graph fields', - (tester) async { - EventEditorDraft? saved; - await tester.pumpWidget( - localizedTestApp( - child: Scaffold( - body: EventEditor( - initialDraft: EventEditorDraft.newEvent( - accountId: 'microsoft-account', - sourceId: 'microsoft-source', - providerCalendarId: 'ms-cal-1', - start: DateTime.utc(2026, 6, 10, 9), - end: DateTime.utc(2026, 6, 10, 10), - ).copyWith(title: 'Planning'), - sources: _microsoftSources, - onCancel: () {}, - onSave: (draft) => saved = draft, - ), - ), - ), + testWidgets('recurrence editor creates a biweekly weekday schedule', ( + tester, + ) async { + EventEditorDraft? saved; + final initial = + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + start: DateTime(2026, 6, 10, 9), + end: DateTime(2026, 6, 10, 10), + ).copyWith( + title: 'Planning', + recurrence: const ['RRULE:FREQ=DAILY;INTERVAL=1;COUNT=10'], ); + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: initial, + sources: _sources, + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ); - final repeatRow = tester.widget>( - find.byWidgetPredicate( - (widget) => - widget is BusyMaxComboRow && widget.title == 'Repeat', + _actionRow(tester, 'Repeat').onTap!(); + await tester.pumpAndSettle(); + final dialog = find.byType(RecurrenceEditorDialog); + expect(dialog, findsOneWidget); + tester + .widget>( + find.descendant( + of: dialog, + matching: find.byType(BusyMaxComboRow), ), - ); - repeatRow.onSelected(recurrenceCase.key); - await tester.pump(); - await tester.tap(_headerButtonFinder('Save')); - - final expectedRecurrence = { - 'pattern': recurrenceCase.value, - 'range': const {'type': 'noEnd', 'startDate': '2026-06-10'}, - }; - final body = microsoftEventMutationToJson( - CalendarEventMutation(recurrence: saved?.recurrence), - ); + ) + .onSelected(RecurrenceFrequency.weekly); + await tester.pump(); - expect(saved, isNotNull); - expect(body, {'recurrence': expectedRecurrence}); - }, + final fields = find.descendant( + of: dialog, + matching: find.byType(TextField), ); - } - - for (final recurrenceCase in _microsoftReanchoredRecurrenceCases.entries) { - testWidgets( - 'Microsoft ${recurrenceCase.key} recurrence follows a changed start date', - (tester) async { - EventEditorDraft? saved; - await tester.pumpWidget( - localizedTestApp( - child: Scaffold( - body: EventEditor( - initialDraft: EventEditorDraft.newEvent( - accountId: 'microsoft-account', - sourceId: 'microsoft-source', - providerCalendarId: 'ms-cal-1', - start: DateTime.utc(2026, 6, 10, 9), - end: DateTime.utc(2026, 6, 10, 10), - ).copyWith(title: 'Planning'), - sources: _microsoftSources, - onCancel: () {}, - onSave: (draft) => saved = draft, - ), - ), - ), - ); + await tester.enterText(fields.at(0), '2'); + await tester.enterText(fields.at(1), '9'); + var weekdays = tester.widget( + find.descendant(of: dialog, matching: find.byType(YaruChoiceChipBar)), + ); + weekdays.onSelected!(0); + await tester.pump(); + weekdays = tester.widget( + find.descendant(of: dialog, matching: find.byType(YaruChoiceChipBar)), + ); + weekdays.onSelected!(4); + await tester.pump(); - final repeatRow = tester.widget>( - find.byWidgetPredicate( - (widget) => - widget is BusyMaxComboRow && widget.title == 'Repeat', - ), - ); - repeatRow.onSelected(recurrenceCase.key); - await tester.pump(); + await tester.tap( + find.descendant( + of: dialog, + matching: find.widgetWithText(ElevatedButton, 'Save'), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Every 2 weeks'), findsOneWidget); + await tester.tap(_headerButtonFinder('Save')); - final startDateRow = tester.widget( - find.byWidgetPredicate( - (widget) => - widget is DesktopDateValueRow && widget.label == 'Start date', + expect(saved?.recurrence, const [ + 'RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;WKST=MO;COUNT=9', + ]); + }); + + testWidgets('Persian recurrence day choices use Persian digits', ( + tester, + ) async { + const initial = RecurrenceRule( + frequency: RecurrenceFrequency.monthly, + interval: 1, + byDay: [], + byMonth: [], + byMonthDay: [15], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + + await tester.pumpWidget( + localizedTestApp( + locale: const Locale('fa'), + child: Scaffold( + body: RecurrenceEditorDialog( + initial: initial, + allDay: true, + baseDate: DateTime(2026, 9, 7), + minimumDate: DateTime(2026, 1, 1), + floating: false, + useNativeDatePicker: false, + limits: RecurrenceRuleLimits.rfc5545, ), - ); - startDateRow.onChanged('2026-07-11'); - await tester.pump(); - await tester.tap(_headerButtonFinder('Save')); - - expect(saved?.recurrence, { - 'pattern': recurrenceCase.value, - 'range': const {'type': 'noEnd', 'startDate': '2026-07-11'}, - }); - }, + ), + ), ); - } + await tester.pumpAndSettle(); - testWidgets( - 'new event converts Google recurrence when Microsoft account is selected', - (tester) async { - EventEditorDraft? saved; - await tester.pumpWidget( - localizedTestApp( - child: Scaffold( - body: EventEditor( - initialDraft: EventEditorDraft.newEvent( - accountId: 'account', - sourceId: 'source', - providerCalendarId: 'cal-1', - start: DateTime.utc(2026, 6, 10, 9), - end: DateTime.utc(2026, 6, 10, 10), - ).copyWith(title: 'Planning'), - sources: _mixedProviderSources, - onCancel: () {}, - onSave: (draft) => saved = draft, - ), + final dialog = find.byType(RecurrenceEditorDialog); + expect( + find.descendant(of: dialog, matching: find.text('۱۵')), + findsOneWidget, + ); + expect( + find.descendant(of: dialog, matching: find.text('15')), + findsNothing, + ); + }); + + testWidgets('Microsoft recurrence range follows a changed start date', ( + tester, + ) async { + EventEditorDraft? saved; + final initial = + EventEditorDraft.newEvent( + accountId: 'microsoft-account', + sourceId: 'microsoft-source', + providerCalendarId: 'ms-cal-1', + start: DateTime(2026, 6, 9, 9), + end: DateTime(2026, 6, 9, 10), + ).copyWith( + title: 'Planning', + recurrence: const { + 'pattern': { + 'type': 'relativeMonthly', + 'interval': 1, + 'daysOfWeek': ['tuesday'], + 'index': 'second', + }, + 'range': { + 'type': 'endDate', + 'startDate': '2026-06-09', + 'endDate': '2026-12-31', + }, + }, + ); + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: initial, + sources: _microsoftSources, + onCancel: () {}, + onSave: (draft) => saved = draft, ), ), - ); + ), + ); - _comboRow(tester, 'Repeat').onSelected('weekly'); - await tester.pump(); - _comboRow(tester, 'Account').onSelected('microsoft-account'); - await tester.pump(); - await tester.tap(_headerButtonFinder('Save')); + tester + .widget( + find.byWidgetPredicate( + (widget) => + widget is DesktopDateValueRow && widget.label == 'Start date', + ), + ) + .onChanged('2026-07-14'); + await tester.pump(); + await tester.tap(_headerButtonFinder('Save')); - expect(saved?.recurrence, { - 'pattern': _microsoftRecurrenceCases['weekly'], - 'range': const {'type': 'noEnd', 'startDate': '2026-06-10'}, - }); - }, - ); + expect(saved?.recurrence, const { + 'pattern': { + 'type': 'relativeMonthly', + 'interval': 1, + 'daysOfWeek': ['tuesday'], + 'index': 'second', + }, + 'range': { + 'type': 'endDate', + 'startDate': '2026-07-14', + 'endDate': '2026-12-31', + }, + }); + }); - testWidgets( - 'new event converts Microsoft recurrence when Google account is selected', - (tester) async { - EventEditorDraft? saved; - await tester.pumpWidget( - localizedTestApp( - child: Scaffold( - body: EventEditor( - initialDraft: EventEditorDraft.newEvent( - accountId: 'microsoft-account', - sourceId: 'microsoft-source', - providerCalendarId: 'ms-cal-1', - start: DateTime.utc(2026, 6, 10, 9), - end: DateTime.utc(2026, 6, 10, 10), - ).copyWith(title: 'Planning'), - sources: _mixedProviderSources, - onCancel: () {}, - onSave: (draft) => saved = draft, - ), + testWidgets('new event converts rich recurrence when provider changes', ( + tester, + ) async { + EventEditorDraft? saved; + final initial = + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + start: DateTime(2026, 6, 10, 9), + end: DateTime(2026, 6, 10, 10), + ).copyWith( + title: 'Planning', + recurrence: const [ + 'RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;WKST=MO;COUNT=9', + ], + ); + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: initial, + sources: _mixedProviderSources, + onCancel: () {}, + onSave: (draft) => saved = draft, ), ), - ); + ), + ); - _comboRow(tester, 'Repeat').onSelected('weekly'); - await tester.pump(); - _comboRow(tester, 'Account').onSelected('account'); - await tester.pump(); - await tester.tap(_headerButtonFinder('Save')); + _comboRow(tester, 'Account').onSelected('microsoft-account'); + await tester.pump(); + await tester.tap(_headerButtonFinder('Save')); - expect(saved?.recurrence, ['RRULE:FREQ=WEEKLY;INTERVAL=1']); - }, - ); + expect(saved?.recurrence, const { + 'pattern': { + 'type': 'weekly', + 'interval': 2, + 'daysOfWeek': ['monday', 'wednesday', 'friday'], + 'firstDayOfWeek': 'monday', + }, + 'range': { + 'type': 'numbered', + 'startDate': '2026-06-10', + 'numberOfOccurrences': 9, + }, + }); + }); testWidgets('recurring occurrence hides series recurrence control', ( tester, @@ -907,7 +976,7 @@ void main() { expect(find.text('Repeat'), findsNothing); }); - testWidgets('DAV recurring event requires an explicit supported scope', ( + testWidgets('DAV recurring event exposes every RFC-supported scope', ( tester, ) async { EventEditorDraft? saved; @@ -942,28 +1011,32 @@ void main() { expect(find.text('Recurring event scope'), findsOneWidget); expect(find.text('Entire series'), findsOneWidget); - expect(find.text('This occurrence'), findsOneWidget); - expect(find.text('This and future (not available)'), findsOneWidget); + expect(find.text('This event'), findsOneWidget); + expect(find.text('This and following events'), findsOneWidget); + expect(find.text('Not supported by this provider.'), findsNothing); expect(_headerButton(tester, 'Save').onPressed, isNull); expect(_actionRow(tester, 'Delete Event').onTap, isNull); - expect( - _actionRow(tester, 'This and future (not available)').enabled, - isFalse, - ); + expect(_actionRow(tester, 'This and following events').enabled, isTrue); - await tester.ensureVisible(find.text('Entire series')); - await tester.tap(find.text('Entire series')); + await tester.ensureVisible(find.text('This and following events')); + await tester.tap(find.text('This and following events')); + await tester.pump(); + expect(_headerButton(tester, 'Save').onPressed, isNull); + await tester.enterText( + find.byType(TextFormField).first, + 'Changed following events', + ); await tester.pump(); expect(_headerButton(tester, 'Save').onPressed, isNotNull); expect(_actionRow(tester, 'Delete Event').onTap, isNotNull); await tester.tap(_headerButtonFinder('Save')); expect( saved?.recurringMutationScope, - RecurringEventMutationScope.entireSeries, + RecurringEventMutationScope.thisAndFuture, ); - await tester.ensureVisible(find.text('This occurrence')); - await tester.tap(find.text('This occurrence')); + await tester.ensureVisible(find.text('This event')); + await tester.tap(find.text('This event')); await tester.pump(); await tester.ensureVisible(find.text('Delete Event')); await tester.tap(find.text('Delete Event')); @@ -971,6 +1044,269 @@ void main() { expect(deletedScope, RecurringEventMutationScope.singleOccurrence); }); + testWidgets('Google recurring event exposes this-and-following scope', ( + tester, + ) async { + EventEditorDraft? saved; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'google-occurrence', + providerRecurringEventId: 'google-series', + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + title: 'Weekly planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + sources: _sources, + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ); + + expect(_actionRow(tester, 'This and following events').enabled, isTrue); + await tester.ensureVisible(find.text('This and following events')); + await tester.tap(find.text('This and following events')); + await tester.pump(); + expect(_headerButton(tester, 'Save').onPressed, isNull); + await tester.enterText( + find.byType(TextFormField).first, + 'Changed following events', + ); + await tester.pump(); + await tester.tap(_headerButtonFinder('Save')); + + expect( + saved?.recurringMutationScope, + RecurringEventMutationScope.thisAndFuture, + ); + }); + + testWidgets( + 'Microsoft recurring event disables undocumented following scope', + (tester) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'microsoft-occurrence', + providerRecurringEventId: 'microsoft-series', + accountId: 'microsoft-account', + sourceId: 'microsoft-source', + providerCalendarId: 'ms-cal-1', + title: 'Weekly planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + sources: _microsoftSources, + onCancel: () {}, + onSave: (_) {}, + ), + ), + ), + ); + + expect(_actionRow(tester, 'This and following events').enabled, isFalse); + expect(find.text('Not supported by this provider.'), findsOneWidget); + }, + ); + + testWidgets('moving a Google series natively allows the entire series', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'google-occurrence', + providerRecurringEventId: 'google-series', + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + title: 'Weekly planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + sources: _multipleSources, + onCancel: () {}, + onSave: (_) {}, + ), + ), + ), + ); + + _comboRow(tester, 'Calendar').onSelected('destination-source'); + await tester.pump(); + + expect(_actionRow(tester, 'This event').enabled, isTrue); + expect(_actionRow(tester, 'This and following events').enabled, isFalse); + expect(_actionRow(tester, 'Entire series').enabled, isTrue); + expect( + find.text( + 'This and following events cannot be moved safely. ' + 'Choose this event or the entire series.', + ), + findsOneWidget, + ); + }); + + testWidgets('native Google move preserves an unsupported recurrence rule', ( + tester, + ) async { + EventEditorDraft? saved; + const recurrence = ['RRULE:FREQ=WEEKLY;BYHOUR=9']; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'google-occurrence', + providerRecurringEventId: 'google-series', + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + title: 'Weekly planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + recurrence: recurrence, + ), + sources: _multipleSources, + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ); + + _comboRow(tester, 'Calendar').onSelected('destination-source'); + await tester.pump(); + await tester.ensureVisible(find.text('Entire series')); + await tester.tap(find.text('Entire series')); + await tester.pump(); + await tester.enterText( + find.byType(TextFormField).first, + 'Moved weekly planning', + ); + await tester.pump(); + await tester.tap(_headerButtonFinder('Save')); + + expect(saved?.recurrence, recurrence); + expect(saved?.recurrenceChanged, isFalse); + expect( + saved?.recurringMutationScope, + RecurringEventMutationScope.entireSeries, + ); + }); + + testWidgets('copying a series requires a locally available recurrence rule', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'google-occurrence', + providerRecurringEventId: 'google-series', + accountId: 'google-account', + sourceId: 'google-work', + providerCalendarId: 'google-work-calendar', + title: 'Weekly planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + sources: _sameNamedCrossAccountSources, + accounts: _eventAccounts, + onCancel: () {}, + onSave: (_) {}, + ), + ), + ), + ); + + _comboRow(tester, 'Account').onSelected('microsoft-account'); + await tester.pump(); + + expect(_actionRow(tester, 'This event').enabled, isTrue); + expect(_actionRow(tester, 'This and following events').enabled, isFalse); + expect(_actionRow(tester, 'Entire series').enabled, isFalse); + expect( + find.text( + 'The recurrence rule is not available locally. ' + 'Move this event instead.', + ), + findsOneWidget, + ); + }); + + testWidgets( + 'an occurrence with unsupported recurrence can move as one event', + (tester) async { + EventEditorDraft? saved; + const recurrence = ['RRULE:FREQ=WEEKLY;BYHOUR=9']; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'google-occurrence', + providerRecurringEventId: 'google-series', + accountId: 'google-account', + sourceId: 'google-work', + providerCalendarId: 'google-work-calendar', + title: 'Weekly planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + recurrence: recurrence, + ), + sources: _sameNamedCrossAccountSources, + accounts: _eventAccounts, + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ); + + _comboRow(tester, 'Account').onSelected('microsoft-account'); + await tester.pump(); + + expect(_comboRow(tester, 'Account').selected, 'microsoft-account'); + expect(_actionRow(tester, 'This event').enabled, isTrue); + expect(_actionRow(tester, 'Entire series').enabled, isFalse); + await tester.ensureVisible(find.text('This event')); + await tester.tap(find.text('This event')); + await tester.pump(); + await tester.enterText( + find.byType(TextFormField).first, + 'Moved occurrence', + ); + await tester.pump(); + await tester.tap(_headerButtonFinder('Save')); + + expect(saved?.sourceId, 'microsoft-work'); + expect(saved?.recurrence, recurrence); + expect( + saved?.recurringMutationScope, + RecurringEventMutationScope.singleOccurrence, + ); + }, + ); + testWidgets( 'DAV event keeps guests display-only and exposes categories and alarms', (tester) async { @@ -1026,19 +1362,74 @@ void main() { }, ); - testWidgets('new Nextcloud recurrence uses RFC RRULE values', (tester) async { + testWidgets( + 'Google event guest cannot manage attendees when invitations are disabled', + (tester) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'google-event', + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + title: 'Shared planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + attendees: const [ + EventAttendeeDraft(email: 'guest@example.com'), + ], + isOrganizer: false, + canManageAttendees: false, + ), + sources: _sources, + onCancel: () {}, + onSave: (_) {}, + ), + ), + ), + ); + + expect(find.text('guest@example.com'), findsOneWidget); + expect(find.text('Add Guest'), findsNothing); + expect(find.text('Add guest email'), findsNothing); + expect( + find.byWidgetPredicate( + (widget) => + widget is BusyMaxComboRow && + widget.title == 'guest@example.com', + ), + findsNothing, + ); + }, + ); + + testWidgets('Nextcloud recurrence editor preserves a rich RFC rule', ( + tester, + ) async { EventEditorDraft? saved; + final initial = + EventEditorDraft.newEvent( + accountId: 'nextcloud-account', + sourceId: 'nextcloud-source', + providerCalendarId: '/calendars/work/', + start: DateTime(2026, 6, 9, 9), + end: DateTime(2026, 6, 9, 10), + ).copyWith( + title: 'Planning', + recurrence: const { + 'rules': ['FREQ=MONTHLY;INTERVAL=1;BYDAY=TU;BYSETPOS=2;COUNT=6'], + 'dates': [], + 'excludedDates': [], + }, + ); await tester.pumpWidget( localizedTestApp( child: Scaffold( body: EventEditor( - initialDraft: EventEditorDraft.newEvent( - accountId: 'nextcloud-account', - sourceId: 'nextcloud-source', - providerCalendarId: '/calendars/work/', - start: DateTime.utc(2026, 6, 8, 9), - end: DateTime.utc(2026, 6, 8, 10), - ).copyWith(title: 'Planning'), + initialDraft: initial, sources: _nextcloudSources, onCancel: () {}, onSave: (draft) => saved = draft, @@ -1047,11 +1438,25 @@ void main() { ), ); - _comboRow(tester, 'Repeat').onSelected('weekly'); - await tester.pump(); + _actionRow(tester, 'Repeat').onTap!(); + await tester.pumpAndSettle(); + final dialog = find.byType(RecurrenceEditorDialog); + expect(find.text('Second'), findsWidgets); + expect(find.text('Tuesday'), findsWidgets); + await tester.tap( + find.descendant( + of: dialog, + matching: find.widgetWithText(ElevatedButton, 'Save'), + ), + ); + await tester.pumpAndSettle(); await tester.tap(_headerButtonFinder('Save')); - expect(saved?.recurrence, ['RRULE:FREQ=WEEKLY;INTERVAL=1']); + expect(saved?.recurrence, const { + 'rules': ['FREQ=MONTHLY;INTERVAL=1;BYDAY=TU;BYSETPOS=2;COUNT=6'], + 'dates': [], + 'excludedDates': [], + }); }); testWidgets('event editor does not show metadata fields', (tester) async { @@ -1118,7 +1523,7 @@ void main() { ); }); - testWidgets('existing event only exposes its original calendar', ( + testWidgets('existing event exposes writable destination calendars', ( tester, ) async { await tester.pumpWidget( @@ -1152,14 +1557,46 @@ void main() { ); expect(calendarRow.selected, 'source'); - expect(calendarRow.values, ['source']); - expect(calendarRow.enabled, isFalse); + expect(calendarRow.values, ['destination-source', 'source']); + expect(calendarRow.enabled, isTrue); final accountRow = _comboRow(tester, 'Account'); expect(accountRow.selected, 'account'); expect(accountRow.values, ['account']); expect(accountRow.enabled, isFalse); }); + testWidgets( + 'existing event excludes calendars whose creation is still pending', + (tester) async { + await tester.pumpWidget( + 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), + ), + sources: _sourcesWithPendingCalendar, + onCancel: () {}, + onSave: (_) {}, + onDelete: (_, _) {}, + ), + ), + ), + ); + + final calendarRow = _comboRow(tester, 'Calendar'); + expect(calendarRow.values, ['destination-source', 'source']); + expect(calendarRow.values, isNot(contains('pending-source'))); + }, + ); + testWidgets('new event can still select any visible calendar', ( tester, ) async { @@ -1193,6 +1630,32 @@ void main() { expect(calendarRow.enabled, isTrue); }); + testWidgets('new event can select a calendar whose creation is pending', ( + tester, + ) async { + await tester.pumpWidget( + 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: _sourcesWithPendingCalendar, + onCancel: () {}, + onSave: (_) {}, + ), + ), + ), + ); + + final calendarRow = _comboRow(tester, 'Calendar'); + expect(calendarRow.values, contains('pending-source')); + }); + testWidgets( 'new event selects an account before choosing among its calendars', (tester) async { @@ -2328,6 +2791,23 @@ const _multipleSources = [ ), ]; +const _sourcesWithPendingCalendar = [ + ..._multipleSources, + CalendarSourceEntity( + id: 'pending-source', + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'local:pending-calendar', + summary: 'Project', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + pendingCreate: true, + backgroundColor: '#f6d32d', + ), +]; + const _nextcloudSources = [ CalendarSourceEntity( id: 'nextcloud-source', @@ -2361,37 +2841,3 @@ const _microsoftSources = [ ]; const _mixedProviderSources = [..._sources, ..._microsoftSources]; - -const _microsoftRecurrenceCases = >{ - 'daily': {'type': 'daily', 'interval': 1}, - 'weekly': { - 'type': 'weekly', - 'interval': 1, - 'daysOfWeek': ['wednesday'], - 'firstDayOfWeek': 'sunday', - }, - 'monthly': {'type': 'absoluteMonthly', 'interval': 1, 'dayOfMonth': 10}, - 'yearly': { - 'type': 'absoluteYearly', - 'interval': 1, - 'dayOfMonth': 10, - 'month': 6, - }, -}; - -const _microsoftReanchoredRecurrenceCases = >{ - 'daily': {'type': 'daily', 'interval': 1}, - 'weekly': { - 'type': 'weekly', - 'interval': 1, - 'daysOfWeek': ['saturday'], - 'firstDayOfWeek': 'sunday', - }, - 'monthly': {'type': 'absoluteMonthly', 'interval': 1, 'dayOfMonth': 11}, - 'yearly': { - 'type': 'absoluteYearly', - 'interval': 1, - 'dayOfMonth': 11, - 'month': 7, - }, -}; diff --git a/test/features/calendar/presentation/event_guest_delivery_dialog_test.dart b/test/features/calendar/presentation/event_guest_delivery_dialog_test.dart index 56249621..0c2424e5 100644 --- a/test/features/calendar/presentation/event_guest_delivery_dialog_test.dart +++ b/test/features/calendar/presentation/event_guest_delivery_dialog_test.dart @@ -1,3 +1,4 @@ +import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor.dart'; @@ -116,6 +117,86 @@ void main() { expect(await result, CalendarGuestUpdatePolicy.doNotSend); }); + testWidgets('copy-delete move explains the provider boundary before saving', ( + tester, + ) async { + Future? result; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () { + result = showBusyMaxEventEditorDialog( + context, + initialDraft: EventEditorDraft.existing( + eventId: 'event-1', + accountId: 'microsoft-account', + sourceId: 'calendar-1', + providerCalendarId: 'provider-calendar-1', + title: 'Planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + sources: const [ + CalendarSourceEntity( + id: 'calendar-1', + accountId: 'microsoft-account', + provider: BusyProvider.microsoft, + providerCalendarId: 'provider-calendar-1', + summary: 'Work', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ), + CalendarSourceEntity( + id: 'calendar-2', + accountId: 'microsoft-account', + provider: BusyProvider.microsoft, + providerCalendarId: 'provider-calendar-2', + summary: 'Personal', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ), + ], + accounts: const [], + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + final calendarRow = tester.widget>( + find.byWidgetPredicate( + (widget) => + widget is BusyMaxComboRow && widget.title == 'Calendar', + ), + ); + calendarRow.onSelected('calendar-2'); + await tester.pump(); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(find.text('Copy event and delete original?'), findsOneWidget); + expect(find.textContaining('Microsoft · Work'), findsOneWidget); + expect(find.textContaining('Microsoft · Personal'), findsOneWidget); + expect(find.textContaining('only after the copy succeeds'), findsOneWidget); + await tester.tap(find.text('Copy and delete')); + await tester.pumpAndSettle(); + + final editorResult = await result; + expect(editorResult?.draft?.sourceId, 'calendar-2'); + }); + testWidgets('Microsoft delete explains that a cancellation will be sent', ( tester, ) async { diff --git a/test/features/feedback/data/feedback_api_client_test.dart b/test/features/feedback/data/feedback_api_client_test.dart index 01131cb9..b3db4f47 100644 --- a/test/features/feedback/data/feedback_api_client_test.dart +++ b/test/features/feedback/data/feedback_api_client_test.dart @@ -78,4 +78,5 @@ const _submission = FeedbackSubmission( subject: 'Calendar issue', message: 'The calendar view did not update.', replyEmail: null, + platform: 'linux', ); diff --git a/test/features/feedback/data/feedback_submission_test.dart b/test/features/feedback/data/feedback_submission_test.dart index ca5698b1..9aaf10cd 100644 --- a/test/features/feedback/data/feedback_submission_test.dart +++ b/test/features/feedback/data/feedback_submission_test.dart @@ -84,6 +84,7 @@ void main() { subject: ' Privacy question ', message: ' Please explain this behavior. ', replyEmail: ' person@example.com ', + platform: 'linux', ); expect(submission.toJson(), { @@ -108,6 +109,7 @@ void main() { subject: 'Calendar issue', message: 'The calendar view did not update.', replyEmail: ' ', + platform: 'linux', technicalDetails: FeedbackTechnicalDetails( osVersion: 'Linux 6.8.0', locale: 'en-CA', diff --git a/test/features/notifications/desktop_notification_service_test.dart b/test/features/notifications/desktop_notification_service_test.dart index 22ea06ab..278df310 100644 --- a/test/features/notifications/desktop_notification_service_test.dart +++ b/test/features/notifications/desktop_notification_service_test.dart @@ -5,7 +5,6 @@ import 'package:busymax/src/app/app_settings.dart'; import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/features/notifications/desktop_notification_service.dart'; import 'package:busymax/src/features/tasks/domain/task_remote_error.dart'; -import 'package:desktop_notifications/desktop_notifications.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -183,7 +182,7 @@ void main() { final service = DesktopNotificationService( backend: backend, settings: AppSettings.defaults().copyWith(notifyDueToday: true), - locale: const Locale('pt'), + locale: const Locale('pt', 'PT'), ); await service.notifyDueToday(2); @@ -337,10 +336,7 @@ void main() { await service.notifyEventReminder('Standup', 'Starts at 9:00 AM'); - expect( - backend.notifications.single.hints.map((hint) => hint.key), - isNot(contains('transient')), - ); + expect(backend.notifications.single.transient, isFalse); }); test('reminder notification default action activates callback', () async { @@ -356,9 +352,12 @@ void main() { 'Starts at 9:00', onActivated: () async => activated = true, ); - await backend.notifications.single.onAction?.call('default'); + await backend.notifications.single.onAction?.call( + 'default', + backend.notifications.single.payload, + ); - expect(backend.notifications.single.actions.single.key, 'default'); + expect(backend.notifications.single.actions.single.id, 'default'); expect(activated, isTrue); }); @@ -439,33 +438,28 @@ class _FakeNotificationBackend implements DesktopNotificationBackend { @override Future notify( - String summary, { - String body = '', - List hints = const [], - List actions = const [], + BusyMaxNotificationRequest request, { DesktopNotificationActionHandler? onAction, }) async { - notifications.add( - _NotificationRecord(summary, body, hints, actions, onAction), - ); + notifications.add(_NotificationRecord(request, onAction)); } + @override + Future cancel(String stableId) async {} + @override Future close() async {} } class _NotificationRecord { - const _NotificationRecord( - this.summary, - this.body, - this.hints, - this.actions, - this.onAction, - ); + const _NotificationRecord(this.request, this.onAction); - final String summary; - final String body; - final List hints; - final List actions; + final BusyMaxNotificationRequest request; final DesktopNotificationActionHandler? onAction; + + String get summary => request.title; + String get body => request.body; + bool get transient => request.transient; + Map? get payload => request.payload; + List get actions => request.actions; } diff --git a/test/features/notifications/notification_schedule_service_test.dart b/test/features/notifications/notification_schedule_service_test.dart index d2cb5c68..3f71320e 100644 --- a/test/features/notifications/notification_schedule_service_test.dart +++ b/test/features/notifications/notification_schedule_service_test.dart @@ -570,6 +570,30 @@ void main() { expect(await database.select(database.notificationSchedule).get(), isEmpty); }); + test('task-list reminder policy controls scheduled task reminders', () async { + await _insertTaskReminder(database, status: 'needsAction'); + await service.rebuildUpcomingTaskNotifications('microsoft:m'); + expect( + await database.select(database.notificationSchedule).get(), + hasLength(1), + ); + + await database + .update(database.taskLists) + .write(const TaskListsCompanion(remindersEnabled: Value(false))); + await service.rebuildUpcomingTaskNotifications('microsoft:m'); + expect(await database.select(database.notificationSchedule).get(), isEmpty); + + await database + .update(database.taskLists) + .write(const TaskListsCompanion(remindersEnabled: Value(true))); + await service.rebuildUpcomingTaskNotifications('microsoft:m'); + expect( + await database.select(database.notificationSchedule).get(), + hasLength(1), + ); + }); + test('server-missing task removes scheduled task reminder', () async { await _insertTaskReminder(database, status: 'needsAction'); await service.rebuildUpcomingTaskNotifications('microsoft:m'); @@ -625,12 +649,14 @@ Future _insertAccount( BusyProvider.google => 'https://accounts.google.com', BusyProvider.appleICloud => 'https://caldav.icloud.com', BusyProvider.nextcloud => 'https://cloud.example.com', + BusyProvider.webCal => 'https://calendar.example.com', }, providerAccountId: id, credentialKind: switch (provider) { BusyProvider.google || BusyProvider.microsoft => 'oauth', BusyProvider.appleICloud => 'apple_app_specific_password', BusyProvider.nextcloud => 'nextcloud_app_password', + BusyProvider.webCal => 'webcal_subscription', }, authState: const Value('signed_in'), grantedScopes: const Value(''), diff --git a/test/features/notifications/notification_scheduler_test.dart b/test/features/notifications/notification_scheduler_test.dart index 87bbd6cc..111b3a50 100644 --- a/test/features/notifications/notification_scheduler_test.dart +++ b/test/features/notifications/notification_scheduler_test.dart @@ -3,7 +3,6 @@ import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/notifications/desktop_notification_service.dart'; import 'package:busymax/src/features/notifications/notification_scheduler.dart'; import 'package:busymax/src/providers/busy_provider.dart'; -import 'package:desktop_notifications/desktop_notifications.dart'; import 'package:drift/drift.dart' hide isNotNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -143,7 +142,7 @@ void main() { ); await _waitUntil(() => backend.notifications.isNotEmpty); - await backend.notifications.single.onAction?.call('default'); + await backend.notifications.single.invoke('default'); expect(activatedRow?.id, 'event|event-1|5'); expect(activatedRow?.sourceType, 'event'); @@ -263,7 +262,7 @@ void main() { await _insertDueTaskNotification(database, now); await scheduler.checkNow(); - await backend.notifications.single.onAction?.call('snooze'); + await backend.notifications.single.invoke('snooze'); var row = await database.select(database.notificationSchedule).getSingle(); expect(row.sentAtUtc, null); @@ -308,12 +307,12 @@ void main() { await scheduler.checkNow(); final notification = backend.notifications.single; - expect(notification.actions.map((action) => action.key), [ + expect(notification.actions.map((action) => action.id), [ 'default', 'snooze', 'dismiss', ]); - await notification.onAction?.call('dismiss'); + await notification.invoke('dismiss'); final row = await database .select(database.notificationSchedule) @@ -324,6 +323,33 @@ void main() { expect(backend.notifications, hasLength(1)); }); + test('duplicate Windows activation is idempotent', () async { + await _insertDueTaskNotification(database, now); + await scheduler.checkNow(); + final row = await database + .select(database.notificationSchedule) + .getSingle(); + + await scheduler.handleActivation( + notificationScheduleId: row.id, + action: 'snooze', + ); + final first = await database + .select(database.notificationSchedule) + .getSingle(); + now = now.add(const Duration(minutes: 1)); + await scheduler.handleActivation( + notificationScheduleId: row.id, + action: 'snooze', + ); + final duplicate = await database + .select(database.notificationSchedule) + .getSingle(); + + expect(duplicate.snoozedUntilUtc, first.snoozedUntilUtc); + expect(duplicate.updatedAtLocal, first.updatedAtLocal); + }); + test('does not notify for a signed-out account', () async { await database .into(database.accounts) @@ -403,36 +429,37 @@ class _FakeNotificationBackend implements DesktopNotificationBackend { @override Future notify( - String summary, { - String body = '', - List hints = const [], - List actions = const [], + BusyMaxNotificationRequest request, { DesktopNotificationActionHandler? onAction, }) async { final failure = error; if (failure != null) throw failure; - notifications.add(_NotificationRecord(summary, body, actions, onAction)); + notifications.add(_NotificationRecord(request, onAction)); final immediateAction = actionBeforeReturn; if (immediateAction != null) { actionBeforeReturn = null; - await onAction?.call(immediateAction); + await onAction?.call(immediateAction, request.payload); } } + @override + Future cancel(String stableId) async {} + @override Future close() async {} } class _NotificationRecord { - const _NotificationRecord( - this.summary, - this.body, - this.actions, - this.onAction, - ); + const _NotificationRecord(this.request, this.onAction); - final String summary; - final String body; - final List actions; + final BusyMaxNotificationRequest request; final DesktopNotificationActionHandler? onAction; + + String get summary => request.title; + String get body => request.body; + List get actions => request.actions; + + Future invoke(String action) async { + await onAction?.call(action, request.payload); + } } diff --git a/test/features/recurrence/domain/event_recurrence_codec_test.dart b/test/features/recurrence/domain/event_recurrence_codec_test.dart new file mode 100644 index 00000000..d87b2ee9 --- /dev/null +++ b/test/features/recurrence/domain/event_recurrence_codec_test.dart @@ -0,0 +1,260 @@ +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_test/flutter_test.dart'; + +void main() { + group('RFC 5545 recurrence', () { + test('round-trips an every-two-weeks weekday set with a count', () { + final rule = EventRecurrenceCodec.decode(BusyProvider.google, const [ + 'RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;WKST=MO;COUNT=12', + ], baseDate: DateTime(2026, 6, 8, 9)); + + expect(rule.isSupported, isTrue); + expect(rule.frequency, RecurrenceFrequency.weekly); + expect(rule.interval, 2); + expect(rule.byDay, ['MO', 'WE', 'FR']); + expect(rule.count, 12); + expect( + EventRecurrenceCodec.encode( + BusyProvider.google, + rule, + baseDate: DateTime(2026, 6, 8, 9), + allDay: false, + timeZone: 'America/Vancouver', + ), + const ['RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;WKST=MO;COUNT=12'], + ); + }); + + test('round-trips the second Tuesday through a timed UNTIL', () { + final rule = EventRecurrenceCodec.decode(BusyProvider.nextcloud, const { + 'rules': [ + 'FREQ=MONTHLY;INTERVAL=1;BYDAY=TU;BYSETPOS=2;' + 'UNTIL=20261209T170000Z', + ], + 'dates': [], + 'excludedDates': [], + }, baseDate: DateTime(2026, 6, 9, 9)); + + expect(rule.isSupported, isTrue); + expect(rule.byDay, ['TU']); + expect(rule.bySetPosition, 2); + expect(rule.untilDateFor(timeZone: 'America/Vancouver'), '2026-12-09'); + expect( + EventRecurrenceCodec.encode( + BusyProvider.nextcloud, + rule, + baseDate: DateTime(2026, 6, 9, 9), + allDay: false, + timeZone: 'America/Vancouver', + ), + const { + 'rules': [ + 'FREQ=MONTHLY;INTERVAL=1;BYDAY=TU;BYSETPOS=2;' + 'UNTIL=20261209T170000Z', + ], + 'dates': [], + 'excludedDates': [], + }, + ); + }); + + test('keeps recurrence sets with exception dates opaque', () { + final rule = EventRecurrenceCodec.decode(BusyProvider.google, const [ + 'RRULE:FREQ=WEEKLY;BYDAY=MO', + 'EXDATE:20260615T160000Z', + ], baseDate: DateTime(2026, 6, 8, 9)); + + expect(rule.isSupported, isFalse); + expect(rule.rawRules, ['FREQ=WEEKLY;BYDAY=MO']); + expect(rule.exceptionDates, ['EXDATE:20260615T160000Z']); + }); + + test('rejects one ordinal set position across multiple yearly months', () { + final decoded = EventRecurrenceCodec.decode(BusyProvider.google, const [ + 'RRULE:FREQ=YEARLY;BYMONTH=9,10;BYDAY=MO;BYSETPOS=1', + ], baseDate: DateTime(2026, 9, 7)); + + expect(decoded.isSupported, isFalse); + expect( + EventRecurrenceCodec.canEncode(BusyProvider.google, decoded), + isFalse, + ); + + const editableShape = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: ['MO'], + byMonth: [9, 10], + byMonthDay: [], + bySetPosition: 1, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + expect(RecurrenceRuleLimits.rfc5545.supports(editableShape), isFalse); + expect( + EventRecurrenceCodec.canEncode(BusyProvider.google, editableShape), + isFalse, + ); + }); + }); + + group('Microsoft Graph recurrence', () { + test('maps a biweekly weekday set ending after occurrences', () { + final rule = RecurrenceRule.fromIcalendar( + rules: const ['FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;WKST=MO;COUNT=9'], + baseDate: DateTime(2026, 6, 8, 9), + ); + + expect( + EventRecurrenceCodec.encode( + BusyProvider.microsoft, + rule, + baseDate: DateTime(2026, 6, 8, 9), + allDay: false, + timeZone: 'Pacific Standard Time', + ), + const { + 'pattern': { + 'type': 'weekly', + 'interval': 2, + 'daysOfWeek': ['monday', 'wednesday', 'friday'], + 'firstDayOfWeek': 'monday', + }, + 'range': { + 'type': 'numbered', + 'startDate': '2026-06-08', + 'numberOfOccurrences': 9, + }, + }, + ); + }); + + test('maps the second Tuesday ending on a date', () { + const recurrence = { + 'pattern': { + 'type': 'relativeMonthly', + 'interval': 1, + 'daysOfWeek': ['tuesday'], + 'index': 'second', + }, + 'range': { + 'type': 'endDate', + 'startDate': '2026-06-09', + 'endDate': '2026-12-31', + 'recurrenceTimeZone': 'Pacific Standard Time', + }, + }; + final rule = EventRecurrenceCodec.decode( + BusyProvider.microsoft, + recurrence, + baseDate: DateTime(2026, 6, 9, 9), + ); + + expect(rule.isSupported, isTrue); + expect(rule.frequency, RecurrenceFrequency.monthly); + expect(rule.byDay, ['TU']); + expect(rule.bySetPosition, 2); + expect(rule.untilDate, '2026-12-31'); + expect( + EventRecurrenceCodec.encode( + BusyProvider.microsoft, + rule, + baseDate: DateTime(2026, 6, 9, 9), + allDay: false, + timeZone: 'Pacific Standard Time', + original: recurrence, + ), + recurrence, + ); + }); + + test('rejects RFC shapes Graph cannot represent', () { + final fifthTuesday = RecurrenceRule.fromIcalendar( + rules: const ['FREQ=MONTHLY;INTERVAL=1;BYDAY=TU;BYSETPOS=5'], + baseDate: DateTime(2026, 6, 9), + ); + final multipleMonthDays = RecurrenceRule.fromIcalendar( + rules: const ['FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=1,15'], + baseDate: DateTime(2026, 6, 1), + ); + + expect( + EventRecurrenceCodec.canEncode(BusyProvider.microsoft, fifthTuesday), + isFalse, + ); + expect( + EventRecurrenceCodec.canEncode( + BusyProvider.microsoft, + multipleMonthDays, + ), + isFalse, + ); + }); + + test('requires the documented Graph range and calendar values', () { + const missingStartDate = { + 'pattern': { + 'type': 'weekly', + 'interval': 1, + 'daysOfWeek': ['monday'], + 'firstDayOfWeek': 'monday', + }, + 'range': {'type': 'noEnd'}, + }; + const invalidEndDate = { + 'pattern': { + 'type': 'weekly', + 'interval': 1, + 'daysOfWeek': ['monday'], + 'firstDayOfWeek': 'monday', + }, + 'range': { + 'type': 'endDate', + 'startDate': '2026-06-08', + 'endDate': '2026-02-31', + }, + }; + const invalidMonth = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: [], + byMonth: [13], + byMonthDay: [1], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + + expect( + EventRecurrenceCodec.decode( + BusyProvider.microsoft, + missingStartDate, + baseDate: DateTime(2026, 6, 8), + ).isSupported, + isFalse, + ); + expect( + EventRecurrenceCodec.decode( + BusyProvider.microsoft, + invalidEndDate, + baseDate: DateTime(2026, 6, 8), + ).isSupported, + isFalse, + ); + expect( + EventRecurrenceCodec.canEncode(BusyProvider.microsoft, invalidMonth), + isFalse, + ); + }); + }); +} diff --git a/test/features/schedule/presentation/schedule_create_menu_test.dart b/test/features/schedule/presentation/schedule_create_menu_test.dart index dc8f6d44..53bb2b0e 100644 --- a/test/features/schedule/presentation/schedule_create_menu_test.dart +++ b/test/features/schedule/presentation/schedule_create_menu_test.dart @@ -80,6 +80,7 @@ void main() { 'label': 'Event', 'icon': 'x-office-calendar-symbolic', 'enabled': true, + 'role': 'command', 'selected': false, 'shortcut': 'E', }, @@ -87,6 +88,7 @@ void main() { 'label': 'Task', 'icon': 'checkbox-checked-symbolic', 'enabled': true, + 'role': 'command', 'selected': false, 'shortcut': 'T', }, diff --git a/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart new file mode 100644 index 00000000..354c19b4 --- /dev/null +++ b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart @@ -0,0 +1,632 @@ +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/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/calendar/data/calendar_collection_creation_service.dart'; +import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_sidebar.dart'; +import 'package:busymax/src/features/task_lists/data/task_lists_repository.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_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../test_localized_app.dart'; + +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); + }); + + testWidgets('Google, Microsoft, and Nextcloud headers expose both actions', ( + tester, + ) async { + for (final provider in [ + BusyProvider.google, + BusyProvider.microsoft, + BusyProvider.nextcloud, + ]) { + await _pumpSidebar(tester, [_account(provider)]); + + expect(find.byTooltip('Options'), findsOneWidget); + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + expect(find.text('New calendar…'), findsOneWidget); + expect(find.text('New task list…'), findsOneWidget); + + await tester.tapAt(const Offset(790, 790)); + await tester.pumpAndSettle(); + } + }); + + testWidgets( + 'same-email accounts use provider-derived indicators and semantics', + (tester) async { + final semantics = tester.ensureSemantics(); + const email = 'shared@example.test'; + await _pumpSidebar(tester, [ + _account( + BusyProvider.google, + id: 'google-shared', + displayName: 'Shared account', + email: email, + ), + _account( + BusyProvider.microsoft, + id: 'microsoft-shared', + displayName: 'Shared account', + email: email, + ), + _account( + BusyProvider.nextcloud, + id: 'nextcloud-shared', + displayName: 'Shared account', + email: email, + ), + ]); + + for (final expected in const [ + ('google-shared', 'G', 'Google'), + ('microsoft-shared', 'M', 'Microsoft'), + ('nextcloud-shared', 'N', 'Nextcloud'), + ]) { + final indicator = find.byKey( + ValueKey(('account-provider-indicator', expected.$1)), + ); + expect(indicator, findsOneWidget); + expect( + find.descendant(of: indicator, matching: find.text(expected.$2)), + findsOneWidget, + ); + expect( + tester + .widget( + find.descendant(of: indicator, matching: find.byType(Tooltip)), + ) + .message, + expected.$3, + ); + expect( + find.bySemanticsLabel('${expected.$3}, Shared account, $email'), + findsOneWidget, + ); + } + semantics.dispose(); + }, + ); + + testWidgets('Nextcloud uses its server host when email is unavailable', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await _pumpSidebar(tester, [ + _account( + BusyProvider.nextcloud, + id: 'nextcloud-custom-port', + authority: + 'https://cloud.example.test:8443/nextcloud/index.php?token=secret', + displayName: 'albert', + ), + _account( + BusyProvider.nextcloud, + id: 'nextcloud-default-port', + authority: 'https://work.example.test:443/nextcloud', + displayName: 'work-user', + ), + ]); + + expect(find.text('cloud.example.test:8443'), findsOneWidget); + expect(find.text('work.example.test'), findsOneWidget); + expect(find.textContaining('/nextcloud'), findsNothing); + expect(find.textContaining('token=secret'), findsNothing); + expect( + find.bySemanticsLabel('Nextcloud, albert, cloud.example.test:8443'), + findsOneWidget, + ); + expect( + find.bySemanticsLabel('Nextcloud, work-user, work.example.test'), + findsOneWidget, + ); + semantics.dispose(); + }); + + testWidgets( + 'provider indicator stays visible with truncated text in both themes', + (tester) async { + for (final brightness in [Brightness.light, Brightness.dark]) { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFFE95464), + ); + const accountId = 'google-long'; + const displayName = + 'A very long account display name that must be truncated'; + const email = 'a-very-long-address-for-sidebar@example.test'; + await _pumpSidebar( + tester, + [ + _account( + BusyProvider.google, + id: accountId, + displayName: displayName, + email: email, + ), + ], + theme: theme, + width: 240, + ); + + final indicator = find.byKey( + const ValueKey(('account-provider-indicator', accountId)), + ); + final indicatorRect = tester.getRect(indicator); + expect(indicatorRect.size, const Size.square(16)); + expect(indicatorRect.left, greaterThanOrEqualTo(0)); + expect(indicatorRect.right, lessThanOrEqualTo(240)); + final decoration = + tester + .widget( + find.descendant( + of: indicator, + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + final indicatorContext = tester.element(indicator); + final colorScheme = Theme.of(indicatorContext).colorScheme; + expect(decoration.shape, BoxShape.circle); + expect(decoration.color, colorScheme.surfaceContainerHighest); + final letter = tester.widget( + find.descendant(of: indicator, matching: find.text('G')), + ); + expect(letter.style?.color, colorScheme.onSurface); + + final name = tester.widget(find.text(displayName)); + final address = tester.widget(find.text(email)); + expect(name.maxLines, 1); + expect(name.overflow, TextOverflow.ellipsis); + expect(address.maxLines, 1); + expect(address.overflow, TextOverflow.ellipsis); + final nameRect = tester.getRect(find.text(displayName)); + final addressRect = tester.getRect(find.text(email)); + expect(nameRect.top, lessThan(addressRect.top)); + expect(nameRect.left, closeTo(addressRect.left, 0.01)); + expect(addressRect.right, lessThan(indicatorRect.left)); + expect(addressRect.center.dy, closeTo(indicatorRect.center.dy, 0.01)); + + await tester.tap( + find.byKey(const ValueKey(('account-collapse', accountId))), + ); + await tester.pumpAndSettle(); + expect(indicator, findsOneWidget); + expect(tester.getRect(indicator).size, const Size.square(16)); + expect(tester.takeException(), isNull); + } + }, + ); + + testWidgets('Apple has no empty collection Options menu', (tester) async { + await _pumpSidebar(tester, [_account(BusyProvider.appleICloud)]); + + expect(find.byTooltip('Options'), findsNothing); + expect(find.text('No calendars synced yet.'), findsOneWidget); + expect(find.text('No task lists synced yet.'), findsOneWidget); + }); + + testWidgets('WebCal is not rendered as a normal provider account group', ( + tester, + ) async { + await _pumpSidebar(tester, [_account(BusyProvider.webCal)]); + + expect(find.text('Subscriptions'), findsOneWidget); + expect(find.text('WebCal account'), findsNothing); + expect(find.byTooltip('Options'), findsNothing); + }); + + testWidgets('Options remains available while collapsed and does not toggle', ( + tester, + ) async { + const accountId = 'google-account'; + await _pumpSidebar(tester, [_account(BusyProvider.google, id: accountId)]); + + expect(find.text('No calendars synced yet.'), findsOneWidget); + await tester.tap( + find.byKey(const ValueKey(('account-collapse', accountId))), + ); + await tester.pumpAndSettle(); + expect(find.text('No calendars synced yet.'), findsNothing); + expect(find.byTooltip('Options'), findsOneWidget); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + expect(find.text('New calendar…'), findsOneWidget); + expect(find.text('No calendars synced yet.'), findsNothing); + }); + + testWidgets('creation actions stay in the header for empty accounts', ( + tester, + ) async { + await _pumpSidebar(tester, [_account(BusyProvider.google)]); + + expect(find.text('No calendars synced yet.'), findsOneWidget); + expect(find.text('No task lists synced yet.'), findsOneWidget); + expect( + find.byKey(const ValueKey(('new-calendar', 'google-account'))), + findsNothing, + ); + expect(find.text('New calendar'), findsNothing); + expect(find.text('New task list'), findsNothing); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + expect(find.text('New calendar…'), findsOneWidget); + expect(find.text('New task list…'), findsOneWidget); + }); + + testWidgets('service switches omit only the disabled collection action', ( + tester, + ) async { + await _pumpSidebar(tester, [ + _account( + BusyProvider.google, + calendarsEnabled: false, + tasksEnabled: true, + ), + ]); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + expect(find.text('New calendar…'), findsNothing); + expect(find.text('New task list…'), findsOneWidget); + }); + + testWidgets('offline Nextcloud keeps actions visible but disabled', ( + tester, + ) async { + await _pumpSidebar(tester, [ + _account(BusyProvider.nextcloud), + ], networkAvailability: NetworkAvailability.offline); + final menu = tester + .widget>( + find.byKey( + const ValueKey(('account-collection-options', 'nextcloud-account')), + ), + ); + + expect(menu.entries, hasLength(2)); + expect(menu.entries.every((entry) => !entry.enabled), isTrue); + }); + + testWidgets('active creation disables repeat activation and runs once', ( + tester, + ) async { + final creationService = _FakeCalendarCollectionCreationService(); + await _pumpSidebar(tester, [ + _account(BusyProvider.google), + ], creationService: creationService); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('New calendar…')); + await tester.pumpAndSettle(); + + final menu = tester + .widget>( + find.byKey( + const ValueKey(('account-collection-options', 'google-account')), + ), + ); + expect( + menu.entries + .singleWhere( + (entry) => entry.value == AccountHeaderCollectionAction.newCalendar, + ) + .enabled, + isFalse, + ); + + await tester.enterText(find.byType(TextField), 'One calendar'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(creationService.titles, ['One calendar']); + }); + + testWidgets('header Options supports keyboard activation', (tester) async { + await _pumpSidebar(tester, [_account(BusyProvider.google)]); + final menuFinder = find.byKey( + const ValueKey(('account-collection-options', 'google-account')), + ); + final focusFinder = find + .descendant(of: menuFinder, matching: find.byType(Focus)) + .first; + tester.widget(focusFinder).focusNode?.requestFocus(); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.text('New calendar…'), findsOneWidget); + expect(find.text('New task list…'), findsOneWidget); + }); + + testWidgets('account-header controls follow RTL without overlap', ( + tester, + ) async { + await _pumpSidebar(tester, [ + _account(BusyProvider.google), + ], locale: const Locale('ar')); + final options = find.byKey( + const ValueKey(('account-collection-options', 'google-account')), + ); + final collapse = find.byKey( + const ValueKey(('account-collapse', 'google-account')), + ); + + expect(Directionality.of(tester.element(options)), TextDirection.rtl); + expect(tester.getRect(options).overlaps(tester.getRect(collapse)), isFalse); + }); + + testWidgets('calendar reminder actions show their current state', ( + tester, + ) async { + await _pumpSidebar( + tester, + [_account(BusyProvider.google)], + calendarSources: const [ + CalendarSourceEntity( + id: 'reminders-on', + accountId: 'google-account', + provider: BusyProvider.google, + providerCalendarId: 'on', + summary: 'Reminders on', + selected: true, + remindersEnabled: true, + hidden: false, + readOnly: false, + isDeleted: false, + ), + CalendarSourceEntity( + id: 'reminders-off', + accountId: 'google-account', + provider: BusyProvider.google, + providerCalendarId: 'off', + summary: 'Reminders off', + selected: true, + remindersEnabled: false, + hidden: false, + readOnly: false, + isDeleted: false, + ), + ], + ); + + final enabledEntry = tester + .widget>( + find.byKey(const ValueKey(('calendar-options', 'reminders-on'))), + ) + .entries + .singleWhere((entry) => entry.value == 'toggle-reminders'); + expect(enabledEntry.label, 'Event reminders — On'); + expect(enabledEntry.icon, Icons.notifications_outlined); + expect(enabledEntry.role, BusyMaxMenuEntryRole.command); + expect(enabledEntry.selected, isFalse); + + final disabledEntry = tester + .widget>( + find.byKey(const ValueKey(('calendar-options', 'reminders-off'))), + ) + .entries + .singleWhere((entry) => entry.value == 'toggle-reminders'); + expect(disabledEntry.label, 'Event reminders — Off'); + expect(disabledEntry.icon, Icons.notifications_off_outlined); + expect(disabledEntry.role, BusyMaxMenuEntryRole.command); + expect(disabledEntry.selected, isFalse); + }); + + testWidgets('task-list reminder actions show their current state', ( + tester, + ) async { + await _pumpSidebar( + tester, + [_account(BusyProvider.google)], + taskLists: const [ + TaskListEntity( + accountId: 'google-account', + id: 'reminders-on', + title: 'Reminders on', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + TaskListEntity( + accountId: 'google-account', + id: 'reminders-off', + title: 'Reminders off', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + remindersEnabled: false, + ), + ], + ); + + final enabledEntry = tester + .widget>( + find.byKey( + const ValueKey(( + 'task-list-options', + 'google-account', + 'reminders-on', + )), + ), + ) + .entries + .singleWhere((entry) => entry.value == 'toggle-reminders'); + expect(enabledEntry.label, 'Task reminders — On'); + expect(enabledEntry.icon, Icons.notifications_outlined); + expect(enabledEntry.role, BusyMaxMenuEntryRole.command); + expect(enabledEntry.selected, isFalse); + + final disabledEntry = tester + .widget>( + find.byKey( + const ValueKey(( + 'task-list-options', + 'google-account', + 'reminders-off', + )), + ), + ) + .entries + .singleWhere((entry) => entry.value == 'toggle-reminders'); + expect(disabledEntry.label, 'Task reminders — Off'); + expect(disabledEntry.icon, Icons.notifications_off_outlined); + expect(disabledEntry.role, BusyMaxMenuEntryRole.command); + expect(disabledEntry.selected, isFalse); + }); +} + +Future _pumpSidebar( + WidgetTester tester, + List accounts, { + CalendarCollectionCreator? creationService, + Locale locale = const Locale('en'), + NetworkAvailability? networkAvailability, + List calendarSources = const [], + List taskLists = const [], + ThemeData? theme, + double width = 320, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(900, 1200); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget( + ProviderScope( + overrides: [ + accountsStreamProvider.overrideWith((ref) => Stream.value(accounts)), + if (networkAvailability != null) + networkAvailabilityProvider.overrideWith( + (ref) => Stream.value(networkAvailability), + ), + calendarRepositoryProvider.overrideWithValue( + _CalendarSourcesRepository(calendarSources), + ), + taskListsRepositoryForAccountProvider.overrideWith( + (ref, accountId) => _TaskListsRepository(taskLists), + ), + if (creationService != null) + calendarCollectionCreationServiceProvider.overrideWithValue( + creationService, + ), + ], + child: localizedTestApp( + locale: locale, + theme: theme, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + height: 800, + child: ScheduleSidebar( + selectedDate: DateTime(2026, 8, 29), + firstWeekday: DateTime.monday, + items: const [], + onDateSelected: (_) {}, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +final class _CalendarSourcesRepository implements CalendarRepository { + const _CalendarSourcesRepository(this.sources); + + final List sources; + + @override + Stream> watchSourcesForAccounts( + List accountIds, + ) => Stream.value([ + for (final source in sources) + if (accountIds.contains(source.accountId)) source, + ]); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _TaskListsRepository implements TaskListsRepository { + const _TaskListsRepository(this.lists); + + final List lists; + + @override + Stream> watchTaskLists() => Stream.value(lists); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _FakeCalendarCollectionCreationService + implements CalendarCollectionCreator { + final titles = []; + + @override + Future createCalendar({ + required String accountId, + required String title, + }) async { + titles.add(title); + return const CalendarCollectionCreationResult( + outcome: CalendarCollectionCreationOutcome.queued, + ); + } +} + +AccountEntity _account( + BusyProvider provider, { + String? id, + bool calendarsEnabled = true, + bool tasksEnabled = true, + String? displayName, + String? email, + String authority = 'https://example.test', +}) => AccountEntity( + id: id ?? '${provider.storageValue}-account', + provider: provider, + authority: authority, + providerAccountId: 'identity', + displayName: + displayName ?? + (provider == BusyProvider.webCal + ? 'WebCal account' + : provider.displayName), + email: email, + authState: accountAuthStateSignedIn, + calendarsEnabled: calendarsEnabled, + tasksEnabled: tasksEnabled, +); diff --git a/test/features/schedule/presentation/schedule_sidebar_provider_test.dart b/test/features/schedule/presentation/schedule_sidebar_provider_test.dart index 9105d6b2..dcc793e1 100644 --- a/test/features/schedule/presentation/schedule_sidebar_provider_test.dart +++ b/test/features/schedule/presentation/schedule_sidebar_provider_test.dart @@ -111,6 +111,7 @@ AccountEntity _account(BusyProvider provider) { BusyProvider.microsoft => 'https://login.microsoftonline.com/common', BusyProvider.appleICloud => 'https://caldav.icloud.com', BusyProvider.nextcloud => 'https://cloud.example.test/nextcloud', + BusyProvider.webCal => 'https://calendar.example.test', }, providerAccountId: 'account', authState: accountAuthStateSignedIn, diff --git a/test/features/schedule/presentation/schedule_toolbar_test.dart b/test/features/schedule/presentation/schedule_toolbar_test.dart index 316e2709..4f283101 100644 --- a/test/features/schedule/presentation/schedule_toolbar_test.dart +++ b/test/features/schedule/presentation/schedule_toolbar_test.dart @@ -76,6 +76,42 @@ void main() { expect(title.style?.fontWeight, FontWeight.bold); }); + testWidgets('day toolbar uses a localized standalone date heading', ( + tester, + ) async { + tester.view + ..physicalSize = const Size(1600, 900) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + localizedTestApp( + locale: const Locale('ru'), + child: Scaffold( + body: SizedBox( + width: 2000, + child: ScheduleToolbar( + mode: ScheduleViewMode.day, + range: ScheduleRange.day(DateTime(2026, 9, 7)), + selectedDate: DateTime(2026, 9, 7), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: false, + canCreateTask: false, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + ), + ), + ), + ), + ); + + expect(find.text('Понедельник, 7 сентября 2026 г.'), findsOneWidget); + }); + testWidgets('Today is an accessible icon-only action', (tester) async { final semantics = tester.ensureSemantics(); var activations = 0; @@ -161,6 +197,7 @@ void main() { 'label': 'Event', 'icon': 'x-office-calendar-symbolic', 'enabled': true, + 'role': 'command', 'selected': false, 'shortcut': 'E', }, @@ -168,6 +205,7 @@ void main() { 'label': 'Task', 'icon': 'checkbox-checked-symbolic', 'enabled': true, + 'role': 'command', 'selected': false, 'shortcut': 'T', }, diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 919c406c..3f0b227f 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -1240,7 +1240,21 @@ void main() { ), ) .toList(); - expect(restingSurfaces, hasLength(4)); + expect(restingSurfaces, hasLength(3)); + final destructiveSurfaces = tester + .widgetList( + find.descendant( + of: find.byType(BusyMaxPopoverIconButton), + matching: find.byWidgetPredicate( + (widget) => + widget is Material && + widget.color == Theme.of(actionContext).colorScheme.error && + widget.shape == const CircleBorder(), + ), + ), + ) + .toList(); + expect(destructiveSurfaces, hasLength(1)); for (final button in find.byType(BusyMaxPopoverIconButton).evaluate()) { expect( tester.getSize(find.byWidget(button.widget)), @@ -1257,7 +1271,7 @@ void main() { } expect( tester.widget(find.byIcon(YaruIcons.trash)).color, - Theme.of(actionContext).colorScheme.error, + Theme.of(actionContext).colorScheme.onError, ); expect(tester.widget(find.byIcon(YaruIcons.share)).color, isNull); @@ -1566,6 +1580,48 @@ void main() { ); }, ); + + testWidgets('destructive popover action uses a visible semantic fill in ' + '$brightness mode', (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: BusyMaxPopoverIconButton( + icon: YaruIcons.trash, + tooltip: 'Delete', + destructive: true, + onPressed: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final action = find.byType(BusyMaxPopoverIconButton); + final destructiveSurface = tester.widget( + find.descendant( + of: action, + matching: find.byWidgetPredicate( + (widget) => + widget is Material && + widget.color == theme.colorScheme.error && + widget.shape == const CircleBorder(), + ), + ), + ); + final icon = tester.widget(find.byIcon(YaruIcons.trash)); + + expect(destructiveSurface.color, theme.colorScheme.error); + expect(destructiveSurface.color, isNot(colors.control)); + expect(icon.color, theme.colorScheme.onError); + }); } testWidgets('popover action retains Yaru keyboard-focus treatment', ( @@ -2837,6 +2893,11 @@ void main() { expect(source, contains('context.l10n.allTasksRefreshed')); expect(source, contains('setScheduleViewMode(mode)')); expect(source, contains('settings.scheduleViewMode')); + expect( + source, + contains('localizedScheduleHeading('), + reason: 'native header state shares the Flutter heading formatter', + ); expect(source, contains('ScheduleEmptyState')); expect(source, isNot(contains('BusyMaxHeaderBarAction.newItem'))); expect(source, isNot(contains('BusyMaxHeaderBarAction.openMenu'))); @@ -3778,7 +3839,7 @@ void main() { expect(sidebar, contains('visibilityButton: _SourceVisibilityButton')); expect(sidebar, contains('menuButton: BusyMaxMenuButton')); expect(sidebar, contains('tooltip: context.l10n.options')); - expect('highlightWhenOpen: false'.allMatches(sidebar), hasLength(2)); + expect('highlightWhenOpen: false'.allMatches(sidebar), hasLength(4)); expect(sidebar, contains('value ? context.l10n.hide : context.l10n.show')); expect(sidebar, isNot(contains('tooltip: context.l10n.sourceCalendar'))); expect(sidebar, isNot(contains('tooltip: context.l10n.sourceTaskList'))); @@ -3794,6 +3855,15 @@ void main() { expect(sidebar, isNot(contains('_SourceVisibilityIndicator'))); }); + test('sidebar does not expose the one-time calendar import action', () { + final sidebar = File( + 'lib/src/features/schedule/presentation/schedule_sidebar.dart', + ).readAsStringSync(); + + expect(sidebar, isNot(contains("ValueKey('import-ics-file')"))); + expect(sidebar, isNot(contains('showIcsImportFlow'))); + }); + test('schedule Create uses a native popover before refresh', () { final workspace = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', @@ -3848,7 +3918,9 @@ void main() { ), ), ); - expect(sidebar, isNot(contains('context.l10n.create'))); + expect(sidebar, isNot(contains('title: context.l10n.create,'))); + expect(sidebar, contains('title: context.l10n.newCalendar')); + expect(sidebar, contains('actionLabel: context.l10n.create')); expect(sidebar, isNot(contains('PushButton.filled'))); expect(toolbar, isNot(contains('BusyMaxShortcutLabels.create'))); expect(toolbar, contains('tooltip: context.l10n.create')); @@ -4069,10 +4141,7 @@ void main() { contains('final showPaging = mode != ScheduleViewMode.agenda'), ); expect(toolbar, contains('if (showPaging)')); - expect( - toolbar, - contains('ScheduleViewMode.agenda => context.l10n.viewAgenda'), - ); + expect(toolbar, contains('agendaLabel: context.l10n.viewAgenda')); expect( workspace, contains('navigationVisible: _mode != ScheduleViewMode.agenda'), @@ -4180,7 +4249,11 @@ void main() { expect(mode, contains('year')); expect(range, contains('factory ScheduleRange.year(DateTime day)')); expect(workspace, contains('ScheduleRange.year(_selectedDate)')); - expect(workspace, contains('DateFormat.y(locale).format(selectedDate)')); + expect( + workspace, + contains('localizedScheduleHeading('), + reason: 'schedule headers use the centralized localized heading helper', + ); expect(workspace, contains('ScheduleYearView(')); expect(yearView, contains('final monthWidth =')); expect(yearView, contains('Wrap(')); diff --git a/test/features/schedule/presentation/schedule_workspace_states_test.dart b/test/features/schedule/presentation/schedule_workspace_states_test.dart index 2b51685c..a98ea017 100644 --- a/test/features/schedule/presentation/schedule_workspace_states_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_states_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/busymax_design.dart'; @@ -9,6 +10,7 @@ import 'package:busymax/src/features/schedule/presentation/schedule_empty_states import 'package:busymax/src/features/schedule/presentation/schedule_sidebar.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/platform/linux_header_bar_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; @@ -284,6 +286,7 @@ void main() { containsPair('searchActive', false), ); }, + skip: !Platform.isLinux, ); } diff --git a/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart b/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart index 60eb9ee6..eb1e950b 100644 --- a/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart @@ -6,6 +6,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/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/platform/linux_header_bar_provider.dart'; import 'package:busymax/src/platform/native_dialog_service.dart'; import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/schedule/schedule_scope.dart'; diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index f9479547..81237f9d 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -83,6 +83,66 @@ void main() { }, ); + test('all-task query is not limited by year or an arbitrary page', () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _insertScheduleAccount(database, provider: BusyProvider.google); + await _insertTaskList(database); + await _insertTask( + database, + id: 'past', + title: 'Past task', + dueUtc: '2020-01-02', + ); + await _insertTask( + database, + id: 'future', + title: 'Future task', + dueUtc: '2040-03-04', + ); + for (var index = 0; index < 501; index += 1) { + await _insertTask( + database, + id: 'undated-$index', + title: 'Undated $index', + ); + } + + final tasks = await ScheduleRepository(database).listAllTasks( + filters: const ScheduleFilters( + accountIds: {'account'}, + includeCalendarEvents: false, + showCompletedTasks: true, + showNoDateTasks: true, + ), + ); + + expect(tasks, hasLength(503)); + expect(tasks.map((item) => item.id), containsAll(['past', 'future'])); + expect(tasks.where((item) => item.start == null), hasLength(501)); + }); + + test( + 'repository ensures calendar projection coverage before reading', + () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + final requestedRange = ScheduleRange.day(DateTime(2035, 8, 29)); + ScheduleRange? ensuredRange; + final repository = ScheduleRepository( + database, + ensureProjectionCoverage: (range) async => ensuredRange = range, + ); + + await repository.listItems( + range: requestedRange, + filters: const ScheduleFilters(includeTasks: false), + ); + + expect(ensuredRange, requestedRange); + }, + ); + test( 'Microsoft all-day calendar event appears from Graph dateTime fields', () async { @@ -138,6 +198,48 @@ void main() { }, ); + test('events from a deleted calendar source are excluded', () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _insertScheduleAccount(database, provider: BusyProvider.microsoft); + final calendarRepository = CalendarRepository(database: database); + await calendarRepository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'calendar', + summary: 'Deleted calendar', + ), + ); + await calendarRepository.upsertEvent( + accountId: 'account', + event: const CalendarEventDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'calendar', + providerEventId: 'event', + title: 'Pending local edit', + startDateTime: '2026-06-11T09:00:00.000Z', + endDateTime: '2026-06-11T10:00:00.000Z', + ), + ); + await (database.update(database.calendarSources) + ..where((row) => row.providerCalendarId.equals('calendar'))) + .write(const CalendarSourcesCompanion(isDeleted: Value(true))); + await (database.update(database.calendarEvents) + ..where((row) => row.providerEventId.equals('event'))) + .write(const CalendarEventsCompanion(syncStatus: Value('pending'))); + + final items = await ScheduleRepository(database).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 11)), + filters: const ScheduleFilters( + accountIds: {'account'}, + includeTasks: false, + ), + ); + + expect(items, isEmpty); + }); + test('Microsoft timed calendar event projects its display times', () async { final database = AppDatabase(NativeDatabase.memory()); addTearDown(database.close); @@ -388,11 +490,86 @@ void main() { expect(event.organizer?['email'], 'organizer@example.com'); expect(event.isOrganizer, isFalse); + expect(event.guestsCanModify, isFalse); + expect(event.locked, isFalse); + expect(event.capabilities.canEdit, isFalse); expect(event.currentUserResponse, 'tentative'); expect(event.canRespondToInvitation, isTrue); expect(event.joinMeetingUrl, 'https://meet.google.com/abc-defg-hij'); }); + test( + 'Google event editability honors organizer, guest, and lock state', + () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _insertScheduleAccount(database, provider: BusyProvider.google); + final calendarRepository = CalendarRepository(database: database); + await calendarRepository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + summary: 'Work', + ), + ); + for (final event in const [ + CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + providerEventId: 'owned', + title: 'Owned event', + startDateTime: '2026-06-11T09:00:00-07:00', + endDateTime: '2026-06-11T10:00:00-07:00', + organizerJson: {'self': true}, + rawJson: {'id': 'owned'}, + ), + CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + providerEventId: 'guest-editable', + title: 'Guest-editable event', + startDateTime: '2026-06-11T11:00:00-07:00', + endDateTime: '2026-06-11T12:00:00-07:00', + organizerJson: {'self': false}, + rawJson: {'id': 'guest-editable', 'guestsCanModify': true}, + ), + CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + providerEventId: 'locked', + title: 'Locked event', + startDateTime: '2026-06-11T13:00:00-07:00', + endDateTime: '2026-06-11T14:00:00-07:00', + organizerJson: {'self': true}, + rawJson: {'id': 'locked', 'locked': true}, + ), + ]) { + await calendarRepository.upsertEvent( + accountId: 'account', + event: event, + ); + } + + final items = await ScheduleRepository(database).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 11)), + filters: const ScheduleFilters( + accountIds: {'account'}, + includeTasks: false, + ), + ); + final byTitle = { + for (final item in items.whereType()) + item.title: item, + }; + + expect(byTitle['Owned event']!.capabilities.canEdit, isTrue); + expect(byTitle['Guest-editable event']!.capabilities.canEdit, isTrue); + expect(byTitle['Locked event']!.capabilities.canEdit, isFalse); + expect(byTitle['Locked event']!.capabilities.canDelete, isTrue); + }, + ); + test('Microsoft invitation and Teams data reach the schedule item', () 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 ad31c8c7..77419f2b 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; @@ -24,8 +25,8 @@ import 'package:busymax/src/features/settings/presentation/settings_screen.dart' import 'package:busymax/src/features/sync/sync_auth_error.dart'; import 'package:busymax/src/platform/gtk_font_service.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/platform/linux_header_bar_provider.dart'; import 'package:busymax/src/platform/native_menu_service.dart'; -import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/providers/provider_capabilities.dart'; @@ -53,6 +54,42 @@ void main() { .setMockMethodCallHandler(_nativeMenuChannel, null); }); + test('Settings defaults generic routes to System', () { + expect(const SettingsScreen().initialPage, SettingsPage.system); + expect(settingsPageFromRouteValue(null), SettingsPage.system); + expect(settingsPageFromRouteValue('unknown'), SettingsPage.system); + expect(settingsPageFromRouteValue('system'), SettingsPage.system); + expect(settingsPageFromRouteValue('accounts'), SettingsPage.accounts); + }); + + testWidgets('Settings opens the System page by default', (tester) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + buildConfig: _configuredBuildConfig, + activeAccountIdOverride: null, + ); + addTearDown(container.dispose); + + await _pumpDefaultSettings( + tester, + container, + logicalSize: const Size(1000, 700), + ); + + expect( + tester + .widget( + find.byKey(const ValueKey('settings-navigation-system')), + ) + .selected, + isTrue, + ); + expect(find.text('Theme'), findsOneWidget); + expect(find.text('Add Google account'), findsNothing); + }); + testWidgets('Settings fallback header uses the semantic title style', ( tester, ) async { @@ -505,12 +542,49 @@ void main() { expect(find.text('Theme'), findsOneWidget); expect(find.text('Current locale'), findsOneWidget); - expect(find.text('Manual full sync'), findsOneWidget); + expect(find.text('Force full resync'), findsNothing); expect(find.text('Launch at login'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Current locale')).dy, + lessThan(tester.getTopLeft(find.text('Show tray icon')).dy), + ); expect(find.text('Theme family'), findsNothing); expect(find.text('Add Google account'), findsNothing); }); + testWidgets('Russian System section uses a standalone noun', (tester) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + buildConfig: _configuredBuildConfig, + activeAccountIdOverride: null, + ); + addTearDown(container.dispose); + + await _pumpSettings( + tester, + container, + logicalSize: const Size(1000, 700), + locale: const Locale('ru'), + ); + + final systemNavigation = find.byKey( + const ValueKey('settings-navigation-system'), + ); + expect( + find.descendant(of: systemNavigation, matching: find.text('Система')), + findsOneWidget, + ); + expect(find.text('Системная'), findsNothing); + + await tester.tap(systemNavigation); + await tester.pumpAndSettle(); + + expect(find.text('Система'), findsNWidgets(2)); + expect(find.text('Системная'), findsWidgets); + }, skip: !Platform.isLinux); + testWidgets('Settings workspace uses the native window surface', ( tester, ) async { @@ -570,6 +644,15 @@ void main() { ); expect(find.byType(YaruNavigationRail), findsNothing); expect(find.byType(BusyMaxSidebarSurface), findsOneWidget); + final navigationTiles = tester + .widgetList( + find.byType(BusyMaxSidebarNavigationTile), + ) + .toList(growable: false); + expect( + navigationTiles.first.key, + const ValueKey('settings-navigation-system'), + ); final accountsTile = tester.widget( find.byKey(const ValueKey('settings-navigation-accounts')), ); @@ -635,7 +718,24 @@ void main() { await tester.tap(find.text('Diagnostics')); await tester.pumpAndSettle(); + expect(find.text('Force full resync'), findsOneWidget); + expect( + find.text( + 'Completely reload data from every connected account. Use this only ' + 'to troubleshoot synchronization problems.', + ), + findsOneWidget, + ); expect(find.text('Google Tasks API'), findsOneWidget); + expect( + tester.widget(find.text('Sync')).style, + tester.widget(find.text('Google Tasks API')).style, + ); + final resyncSection = tester.getRect( + find.byKey(const ValueKey('diagnostics-full-resync-section')), + ); + final diagnosticsContent = tester.getRect(find.text('Google Tasks API')); + expect(diagnosticsContent.top - resyncSection.bottom, BusyMaxSpacing.lg); expect(find.text('Accounts'), findsOneWidget); expect(find.text('System'), findsOneWidget); expect(find.text('Add Google account'), findsNothing); @@ -745,44 +845,31 @@ void main() { expect(second.state.scheduleDayEndMinute, 24 * 60); }); - testWidgets('Settings creates a new task list for the account card', ( + testWidgets('Settings owns calendar import but not task-list creation', ( tester, ) async { - final googleLists = _FakeTaskListsRepository(); - final microsoftLists = _FakeTaskListsRepository(); final container = _container( selectedAccountId: 'google:g', authRepository: _FakeAuthRepository(), accounts: const [_googleAccount, _microsoftAccount], buildConfig: _configuredBuildConfig, - taskListRepositories: { - 'google:g': googleLists, - 'microsoft:m': microsoftLists, - }, ); addTearDown(container.dispose); await _pumpSettings(tester, container); - final newTaskListButtons = find.text('New task list'); - expect(newTaskListButtons, findsNWidgets(2)); - - await tester.ensureVisible(newTaskListButtons.at(1)); - await tester.tap(newTaskListButtons.at(1)); - await tester.pumpAndSettle(); - - final promptField = find.descendant( - of: find.byType(BusyMaxPromptDialog), - matching: find.byType(TextField), + expect(find.text('New task list'), findsNothing); + expect(find.text('Calendar import'), findsOneWidget); + expect( + find.text( + 'Select a file, review its events, then choose the writable calendar ' + 'that should receive them.', + ), + findsOneWidget, ); - expect(promptField, findsOneWidget); - await tester.enterText(promptField, 'Client work'); - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pumpAndSettle(); - - expect(googleLists.createdTitles, isEmpty); - expect(microsoftLists.createdTitles, ['Client work']); - expect(container.read(selectedAccountIdProvider), 'google:g'); + expect(find.byKey(const ValueKey('import-ics-file')), findsOneWidget); + expect(find.text('Import .ics file'), findsOneWidget); + expect(find.text('Remove account…'), findsNWidgets(2)); }); testWidgets('Settings lists accounts without account switching controls', ( @@ -906,7 +993,6 @@ ProviderContainer _container({ required _FakeAuthRepository authRepository, required List accounts, BuildConfig buildConfig = _emptyBuildConfig, - Map? taskListRepositories, String? activeAccountIdOverride = _useDefaultActiveAccountId, bool useFlutterHeader = false, DavAccountOnboardingService? davOnboardingService, @@ -934,7 +1020,6 @@ ProviderContainer _container({ if (activeAccountIdOverride != _useDefaultActiveAccountId) activeAccountProvider.overrideWithValue(activeAccountIdOverride), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), - syncEngineProvider.overrideWithValue(null), buildConfigProvider.overrideWithValue(buildConfig), if (useFlutterHeader) linuxHeaderBarServiceProvider.overrideWith((ref) { @@ -942,9 +1027,6 @@ ProviderContainer _container({ ref.onDispose(service.dispose); return service; }), - taskListsRepositoryForAccountProvider.overrideWith((ref, accountId) { - return taskListRepositories?[accountId] ?? _FakeTaskListsRepository(); - }), ], ); } @@ -961,6 +1043,8 @@ Future _pumpSettings( ProviderContainer container, { Size? logicalSize, TextScaler? textScaler, + Locale locale = const Locale('en'), + SettingsPage initialPage = SettingsPage.accounts, }) async { if (logicalSize != null) { tester.view.devicePixelRatio = 1; @@ -969,17 +1053,37 @@ Future _pumpSettings( addTearDown(tester.view.resetPhysicalSize); } final settings = textScaler == null - ? const SettingsScreen() + ? SettingsScreen(initialPage: initialPage) : Builder( builder: (context) => MediaQuery( data: MediaQuery.of(context).copyWith(textScaler: textScaler), - child: const SettingsScreen(), + child: SettingsScreen(initialPage: initialPage), ), ); await tester.pumpWidget( UncontrolledProviderScope( container: container, - child: localizedTestApp(child: settings), + child: localizedTestApp(locale: locale, child: settings), + ), + ); + await tester.pumpAndSettle(); +} + +Future _pumpDefaultSettings( + WidgetTester tester, + ProviderContainer container, { + Size? logicalSize, +}) async { + if (logicalSize != null) { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = logicalSize; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + } + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: localizedTestApp(child: const SettingsScreen()), ), ); await tester.pumpAndSettle(); @@ -993,7 +1097,11 @@ Future _pumpRoutedSettings( final router = GoRouter( initialLocation: initialLocation, routes: [ - GoRoute(path: '/settings', builder: (_, _) => const SettingsScreen()), + GoRoute( + path: '/settings', + builder: (_, _) => + const SettingsScreen(initialPage: SettingsPage.accounts), + ), GoRoute( path: '/schedule', builder: (_, _) => const Text('schedule route'), @@ -1086,18 +1194,6 @@ class _FakeAccountsRepository implements AccountsRepository { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -class _FakeTaskListsRepository implements TaskListsRepository { - final createdTitles = []; - - @override - Future createTaskList(String title) async { - createdTitles.add(title); - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - class _MemorySettingsStore implements LocalSettingsStore { Map value = {}; diff --git a/test/features/sync/account_sync_operations_test.dart b/test/features/sync/account_sync_operations_test.dart new file mode 100644 index 00000000..74d376b8 --- /dev/null +++ b/test/features/sync/account_sync_operations_test.dart @@ -0,0 +1,154 @@ +import 'dart:async'; + +import 'package:busymax/src/features/sync/account_sync_operations.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'routes providers explicitly and never creates a WebCal task sync', + () async { + var provider = BusyProvider.webCal; + final calls = []; + final operations = RoutingAccountSyncOperations( + providerForAccount: (_) async => provider, + syncDav: (_, {required full}) async => calls.add('dav:$full'), + syncWebCal: (_, {required full}) async => calls.add('webcal:$full'), + syncTasksRest: (_, {required full}) async => calls.add('tasks:$full'), + syncCalendarRest: (_, {required full}) async => + calls.add('calendar:$full'), + ); + + await operations.syncAccount('account', full: false); + await operations.syncCalendar('account', full: true); + await operations.syncTasks('account', full: true); + expect(calls, ['webcal:false', 'webcal:true']); + + calls.clear(); + provider = BusyProvider.nextcloud; + await operations.syncAccount('account', full: false); + expect(calls, ['dav:false']); + + calls.clear(); + provider = BusyProvider.google; + await operations.syncAccount('account', full: true); + expect(calls, ['tasks:true', 'calendar:true']); + }, + ); + + test('serializes every sync entry point for the same account', () async { + final inner = _BlockingAccountSyncOperations(); + final operations = CoordinatedAccountSyncOperations( + inner: inner, + coordinator: AccountSyncCoordinator(), + ); + + final tasks = operations.syncTasks('account', full: false); + await _waitFor(() => inner.calls.length == 1); + final calendar = operations.syncCalendar('account', full: false); + final account = operations.syncAccount('account', full: true); + await Future.delayed(Duration.zero); + + expect(inner.calls, ['tasks:account:false']); + + inner.releases[0].complete(); + await _waitFor(() => inner.calls.length == 2); + expect(inner.calls, ['tasks:account:false', 'calendar:account:false']); + + inner.releases[1].complete(); + await _waitFor(() => inner.calls.length == 3); + expect(inner.calls, [ + 'tasks:account:false', + 'calendar:account:false', + 'account:account:true', + ]); + + inner.releases[2].complete(); + await Future.wait([tasks, calendar, account]); + expect(inner.maxActive, 1); + }); + + test('allows different accounts to synchronize concurrently', () async { + final inner = _BlockingAccountSyncOperations(); + final operations = CoordinatedAccountSyncOperations( + inner: inner, + coordinator: AccountSyncCoordinator(), + ); + + final first = operations.syncTasks('first', full: false); + final second = operations.syncCalendar('second', full: false); + await _waitFor(() => inner.calls.length == 2); + + expect(inner.calls, ['tasks:first:false', 'calendar:second:false']); + expect(inner.maxActive, 2); + + for (final release in inner.releases) { + release.complete(); + } + await Future.wait([first, second]); + }); + + test('a failed sync does not poison later work for the account', () async { + final coordinator = AccountSyncCoordinator(); + final releaseFailure = Completer(); + final failure = StateError('sync failed'); + var secondRan = false; + + final first = coordinator.run('account', () async { + await releaseFailure.future; + throw failure; + }); + final firstExpectation = expectLater(first, throwsA(same(failure))); + final second = coordinator.run('account', () async { + secondRan = true; + }); + + expect(secondRan, isFalse); + releaseFailure.complete(); + await firstExpectation; + await second; + + expect(secondRan, isTrue); + }); +} + +final class _BlockingAccountSyncOperations implements AccountSyncOperations { + final calls = []; + final releases = >[]; + var active = 0; + var maxActive = 0; + + @override + Future syncAccount(String accountId, {required bool full}) { + return _record('account:$accountId:$full'); + } + + @override + Future syncCalendar(String accountId, {required bool full}) { + return _record('calendar:$accountId:$full'); + } + + @override + Future syncTasks(String accountId, {required bool full}) { + return _record('tasks:$accountId:$full'); + } + + Future _record(String call) async { + final release = Completer(); + calls.add(call); + releases.add(release); + active += 1; + if (active > maxActive) maxActive = active; + await release.future; + active -= 1; + } +} + +Future _waitFor(bool Function() condition) async { + final deadline = DateTime.now().add(const Duration(seconds: 2)); + while (DateTime.now().isBefore(deadline)) { + if (condition()) return; + await Future.delayed(const Duration(milliseconds: 10)); + } + fail('Timed out waiting for condition.'); +} diff --git a/test/features/sync/calendar_event_clear_patch_test.dart b/test/features/sync/calendar_event_clear_patch_test.dart index 34aaf247..388887ca 100644 --- a/test/features/sync/calendar_event_clear_patch_test.dart +++ b/test/features/sync/calendar_event_clear_patch_test.dart @@ -154,6 +154,9 @@ _editAndReplay({ endTimeZone: 'UTC', recurrenceJson: recurrence, attendeesJson: attendees, + organizerJson: provider == BusyProvider.google + ? const {'self': true} + : null, updatedAtServer: '2026-06-08T00:00:00.000Z', rawJson: _eventJson( provider, @@ -275,6 +278,7 @@ Map _googleEventJson({ 'summary': edited ? 'Edited planning' : 'Planning', 'start': {'dateTime': '2026-06-08T09:00:00.000Z', 'timeZone': 'UTC'}, 'end': {'dateTime': '2026-06-08T10:00:00.000Z', 'timeZone': 'UTC'}, + 'organizer': {'self': true}, if (includeRecurrence) 'recurrence': ['RRULE:FREQ=WEEKLY'], if (includeAttendees) 'attendees': [ diff --git a/test/features/sync/calendar_pending_ops_replayer_test.dart b/test/features/sync/calendar_pending_ops_replayer_test.dart index bf49f0ba..4287d6c0 100644 --- a/test/features/sync/calendar_pending_ops_replayer_test.dart +++ b/test/features/sync/calendar_pending_ops_replayer_test.dart @@ -1,5 +1,8 @@ +import 'dart:async'; import 'dart:convert'; +import 'package:busymax/src/calendar_providers/calendar_colors.dart'; +import 'package:busymax/src/calendar_providers/calendar_create_identity.dart'; import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; import 'package:busymax/src/calendar_providers/calendar_provider_capabilities.dart'; import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; @@ -9,7 +12,9 @@ import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; import 'package:busymax/src/features/sync/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'; import 'package:busymax/src/google_calendar/google_calendar_errors.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_errors.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; @@ -30,6 +35,7 @@ void main() { providerCalendarId: 'cal-1', summary: 'Work', timeZone: 'America/Vancouver', + dataOwner: 'me@example.com', ), ); }); @@ -65,7 +71,10 @@ void main() { isEmpty, ); final rows = await database.select(database.calendarEvents).get(); - expect(rows.single.providerEventId, 'server-event-1'); + expect( + rows.single.providerEventId, + client.createdMutations.single.providerEventId, + ); expect(rows.single.startTimeZone, 'America/Vancouver'); expect(rows.single.endTimeZone, 'America/Vancouver'); expect(client.createdMutations.single.startTimeZone, 'America/Vancouver'); @@ -74,6 +83,154 @@ void main() { }, ); + test('concurrent replayers dispatch a queued event create once', () async { + await CalendarRepository(database: database).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: 'Planning'), + ); + final createGate = Completer(); + client.createEventGate = createGate; + + CalendarPendingOpsReplayer replayer() => CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ); + + final first = replayer().replayDueOps(); + await _waitFor(() => client.calls.length == 1); + final second = replayer().replayDueOps(); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(client.calls, ['createEvent:cal-1:Planning']); + + createGate.complete(); + expect(await first, 1); + expect(await second, 0); + expect(client.calls, ['createEvent:cal-1:Planning']); + }); + + test('Google create retry reuses its ID and reconciles a 409', () async { + final repository = CalendarRepository(database: database); + final operationId = 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: 'Planning'), + ); + final expectedEventId = googleCalendarCreateEventId(operationId); + final queued = await database.pendingOpsDao.getOp(operationId); + expect( + jsonDecode(queued!.requestJson), + containsPair(calendarEventGoogleCreateIdKey, expectedEventId), + ); + client.createEventResponseError = StateError('response lost'); + + final firstApplied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + final secondApplied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(); + + expect(firstApplied, 0); + expect(secondApplied, 1); + expect( + client.createdMutations.map((mutation) => mutation.providerEventId), + [expectedEventId, expectedEventId], + ); + expect(client.distinctCreatedEventCount, 1); + expect(client.calls, [ + 'createEvent:cal-1:Planning', + 'createEvent:cal-1:Planning', + 'getEvent:cal-1:$expectedEventId', + ]); + final local = await database.select(database.calendarEvents).getSingle(); + expect(local.providerEventId, expectedEventId); + expect(await database.pendingOpsDao.getOp(operationId), equals(null)); + }); + + test('Microsoft create retry reuses its transaction ID', () async { + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-ms', + summary: 'Microsoft calendar', + ), + ); + final operationId = await repository.createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'account|microsoft|cal-ms', + providerCalendarId: 'cal-ms', + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith(title: 'Planning'), + ); + final expectedTransactionId = microsoftCalendarCreateTransactionId( + operationId, + ); + final queued = await database.pendingOpsDao.getOp(operationId); + expect( + jsonDecode(queued!.requestJson), + containsPair( + calendarEventMicrosoftTransactionIdKey, + expectedTransactionId, + ), + ); + final microsoftClient = _FakeMicrosoftCalendarClient() + ..createEventResponseError = StateError('response lost'); + + final firstApplied = await CalendarPendingOpsReplayer( + database: database, + client: microsoftClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + final secondApplied = await CalendarPendingOpsReplayer( + database: database, + client: microsoftClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(); + + expect(firstApplied, 0); + expect(secondApplied, 1); + expect( + microsoftClient.createdMutations.map( + (mutation) => mutation.transactionId, + ), + [expectedTransactionId, expectedTransactionId], + ); + expect(microsoftClient.distinctCreatedEventCount, 1); + expect(microsoftClient.calls, [ + 'createEvent:cal-ms:Planning', + 'createEvent:cal-ms:Planning', + ]); + final local = await (database.select( + database.calendarEvents, + )..where((row) => row.provider.equals('microsoft'))).getSingle(); + expect(local.providerEventId, 'server-event-1'); + expect(await database.pendingOpsDao.getOp(operationId), equals(null)); + }); + test('event replay preserves a do-not-send guest update choice', () async { await CalendarRepository(database: database).createLocalEvent( EventEditorDraft.newEvent( @@ -173,7 +330,7 @@ void main() { ); test( - 'local Google event edit uses app local timezone over UTC event', + 'title-only Google edit preserves the explicit UTC representation', () async { await CalendarRepository(database: database).upsertSource( accountId: 'account', @@ -191,22 +348,13 @@ void main() { endTimeZone: 'UTC', ); - await CalendarRepository( + final repository = CalendarRepository( database: database, localTimeZone: 'America/Vancouver', - ).updateLocalEvent( - EventEditorDraft.existing( - eventId: eventId, - accountId: 'account', - sourceId: 'account|google|cal-1', - providerCalendarId: 'cal-1', - title: 'Patched', - allDay: false, - start: DateTime(2026, 6, 8, 9), - end: DateTime(2026, 6, 8, 10), - startTimeZone: 'UTC', - endTimeZone: 'UTC', - ), + ); + final detail = await repository.loadEventDetail(eventId); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith(title: 'Patched'), ); final event = await (database.select( @@ -218,113 +366,571 @@ void main() { .getSingle(); final request = jsonDecode(op.requestJson) as Map; - expect(event.startTimeZone, 'America/Vancouver'); - expect(event.endTimeZone, 'America/Vancouver'); - expect(request['startTimeZone'], 'America/Vancouver'); - expect(request['endTimeZone'], 'America/Vancouver'); + expect(event.startDateTime, '2026-06-08T09:00:00.000Z'); + expect(event.endDateTime, '2026-06-08T10:00:00.000Z'); + expect(event.startTimeZone, 'UTC'); + expect(event.endTimeZone, 'UTC'); + expect(request, { + 'title': 'Patched', + calendarEventGuestUpdatePolicyKey: 'send', + }); }, ); + test('same-account Google move uses the native provider operation', () async { + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-2', + summary: 'Personal', + timeZone: 'America/Vancouver', + ), + ); + final eventId = await _insertEvent( + database, + providerEventId: 'provider-event', + startTimeZone: 'UTC', + endTimeZone: 'UTC', + ); + + await repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: eventId, + accountId: 'account', + sourceId: 'account|google|cal-2', + providerCalendarId: 'cal-2', + title: 'Moved', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + startTimeZone: 'UTC', + endTimeZone: 'UTC', + ), + guestUpdatePolicy: CalendarGuestUpdatePolicy.doNotSend, + ); + + final queued = await (database.select( + database.pendingOps, + )..orderBy([(row) => OrderingTerm.asc(row.createdAtUtc)])).get(); + expect(queued.map((op) => op.operationType), ['event.move', 'event.patch']); + expect(queued.last.dependsOnOpId, queued.first.id); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 2); + expect(client.calls, [ + 'getEvent:cal-1:provider-event', + 'moveEvent:cal-1:provider-event:cal-2', + 'updateEvent:cal-2:provider-event:Moved', + ]); + expect( + client.guestUpdatePolicies, + everyElement(CalendarGuestUpdatePolicy.doNotSend), + ); + final event = await database.select(database.calendarEvents).getSingle(); + expect(event.providerCalendarId, 'cal-2'); + expect(event.calendarSourceId, 'account|google|cal-2'); + expect(event.title, 'Moved'); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + test('calendar-only Google move does not rewrite event fields', () async { + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-2', + summary: 'Personal', + timeZone: 'America/Vancouver', + ), + ); + final eventId = await _insertEvent( + database, + providerEventId: 'provider-event', + startTimeZone: 'UTC', + endTimeZone: 'UTC', + ); + + await repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: eventId, + accountId: 'account', + sourceId: 'account|google|cal-2', + providerCalendarId: 'cal-2', + title: 'Base', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + startTimeZone: 'UTC', + endTimeZone: 'UTC', + ), + ); + + final queued = await database.select(database.pendingOps).get(); + expect(queued, hasLength(1)); + expect(queued.single.operationType, 'event.move'); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 1); + expect(client.calls, [ + 'getEvent:cal-1:provider-event', + 'moveEvent:cal-1:provider-event:cal-2', + ]); + }); + + test('native Google move rejects a pending-created destination', () async { + final repository = CalendarRepository(database: database); + final destinationId = await repository.createLocalSource( + accountId: 'account', + summary: 'Pending destination', + ); + final destination = await (database.select( + database.calendarSources, + )..where((row) => row.id.equals(destinationId))).getSingle(); + final eventId = await _insertEvent( + database, + providerEventId: 'provider-event', + ); + + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: eventId, + accountId: 'account', + sourceId: destination.id, + providerCalendarId: destination.providerCalendarId, + title: 'Moved', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + ), + throwsA( + isA() + .having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.moveEvent, + ) + .having( + (error) => error.reason, + 'reason', + CalendarMutationDenialReason.destinationPendingCreate, + ), + ), + ); + + final operations = await database.select(database.pendingOps).get(); + expect(operations, hasLength(1)); + expect(operations.single.operationType, 'calendar.create'); + expect( + (await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle()).syncStatus, + 'synced', + ); + + await repository.deleteLocalSource(destinationId); + + expect(await database.select(database.pendingOps).get(), isEmpty); + expect( + await (database.select( + database.calendarSources, + )..where((row) => row.id.equals(destinationId))).getSingleOrNull(), + equals(null), + ); + expect( + (await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle()).syncStatus, + 'synced', + ); + }); + test( - 'existing event identity cannot move between accounts or calendars', + 'same-account copy move rejects a pending-created destination', () async { - await database - .into(database.accounts) - .insert( - AccountsCompanion.insert( - id: 'microsoft-account', - provider: 'microsoft', - authority: 'https://login.microsoftonline.com/common', - providerAccountId: 'microsoft-account', - 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 CalendarRepository(database: database).upsertSource( + final repository = CalendarRepository(database: database); + final destinationId = await repository.createLocalSource( accountId: 'account', - source: const CalendarSourceDto( - provider: BusyProvider.google, - providerCalendarId: 'cal-2', - summary: 'Personal', - timeZone: 'America/Vancouver', - ), + summary: 'Pending destination', ); - await CalendarRepository(database: database).upsertSource( - accountId: 'microsoft-account', - source: const CalendarSourceDto( - provider: BusyProvider.microsoft, - providerCalendarId: 'ms-cal-1', - summary: 'Outlook', - timeZone: 'America/Vancouver', + final destination = await (database.select( + database.calendarSources, + )..where((row) => row.id.equals(destinationId))).getSingle(); + final eventId = await _insertEvent( + database, + providerEventId: 'provider-occurrence', + providerRecurringEventId: 'provider-series', + ); + + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: eventId, + providerRecurringEventId: 'provider-series', + recurringMutationScope: + RecurringEventMutationScope.singleOccurrence, + accountId: 'account', + sourceId: destination.id, + providerCalendarId: destination.providerCalendarId, + title: 'Copied occurrence', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + ), + throwsA( + isA().having( + (error) => error.reason, + 'reason', + CalendarMutationDenialReason.destinationPendingCreate, + ), ), ); + + final createOperation = await database + .select(database.pendingOps) + .getSingle(); + await repository.discardPendingCalendarCreation(createOperation); + + expect(await database.select(database.pendingOps).get(), isEmpty); + expect( + await (database.select( + database.calendarSources, + )..where((row) => row.id.equals(destinationId))).getSingleOrNull(), + equals(null), + ); + final original = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle(); + expect(original.syncStatus, 'synced'); + expect(original.isDeleted, isFalse); + }, + ); + + test( + 'cross-account copy move rejects a pending-created destination', + () async { + await _insertDestinationGoogleAccount(database); + final repository = CalendarRepository(database: database); + final destinationId = await repository.createLocalSource( + accountId: 'destination-account', + summary: 'Pending destination', + ); + final destination = await (database.select( + database.calendarSources, + )..where((row) => row.id.equals(destinationId))).getSingle(); final eventId = await _insertEvent( database, providerEventId: 'provider-event', ); + + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: eventId, + accountId: 'destination-account', + sourceId: destination.id, + providerCalendarId: destination.providerCalendarId, + title: 'Cross-account copy', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + ), + throwsA( + isA().having( + (error) => error.reason, + 'reason', + CalendarMutationDenialReason.destinationPendingCreate, + ), + ), + ); + + final operations = await database.select(database.pendingOps).get(); + expect(operations, hasLength(1)); + expect(operations.single.operationType, 'calendar.create'); + expect(operations.single.accountId, 'destination-account'); + final original = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle(); + expect(original.syncStatus, 'synced'); + expect(original.isDeleted, isFalse); + }, + ); + + test('native Google series move removes stale source occurrences', () async { + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-2', + summary: 'Personal', + timeZone: 'UTC', + ), + ); + await repository.upsertEvent( + accountId: 'account', + event: _googleSeriesMaster(), + ); + final occurrenceId = await _insertGoogleOccurrence(repository, day: 8); + await _insertGoogleOccurrence(repository, day: 15); + client.remoteEvent = _googleSeriesMaster(); + + await repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: occurrenceId, + providerRecurringEventId: 'series-master', + recurringMutationScope: RecurringEventMutationScope.entireSeries, + accountId: 'account', + sourceId: 'account|google|cal-2', + providerCalendarId: 'cal-2', + title: 'Base', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + startTimeZone: 'UTC', + endTimeZone: 'UTC', + ), + ); + + final operation = await database.select(database.pendingOps).getSingle(); + expect(operation.operationType, 'event.move'); + expect( + (jsonDecode(operation.requestJson) + as Map)[calendarEventRecurringScopeKey], + RecurringEventMutationScope.entireSeries.name, + ); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 1); + expect(client.calls, ['moveEvent:cal-1:series-master:cal-2']); + final events = await database.select(database.calendarEvents).get(); + expect( + events.where((event) => event.providerCalendarId == 'cal-1'), + isEmpty, + ); + expect(events, hasLength(1)); + expect(events.single.providerCalendarId, 'cal-2'); + expect(events.single.providerEventId, 'series-master'); + }); + + test( + 'cross-provider move queues copy before deleting the original', + () async { + await _insertMicrosoftAccountAndSource(database); final repository = CalendarRepository(database: database); - final changedIdentities = - <({String accountId, String sourceId, String providerCalendarId})>[ - ( - accountId: 'other-account', - sourceId: 'account|google|cal-1', - providerCalendarId: 'cal-1', - ), - ( - accountId: 'account', - sourceId: 'account|google|cal-2', - providerCalendarId: 'cal-1', - ), - ( - accountId: 'account', - sourceId: 'account|google|cal-1', - providerCalendarId: 'cal-2', - ), - ( - accountId: 'account', - sourceId: 'account|google|cal-2', - providerCalendarId: 'cal-2', - ), - ( - accountId: 'microsoft-account', - sourceId: 'microsoft-account|microsoft|ms-cal-1', - providerCalendarId: 'ms-cal-1', - ), - ]; - - for (final identity in changedIdentities) { - await expectLater( - repository.updateLocalEvent( - EventEditorDraft.existing( - eventId: eventId, - accountId: identity.accountId, - sourceId: identity.sourceId, - providerCalendarId: identity.providerCalendarId, - title: 'Moved', - allDay: false, - start: DateTime.utc(2026, 6, 8, 9), - end: DateTime.utc(2026, 6, 8, 10), + final eventId = await _insertEvent( + database, + providerEventId: 'provider-event', + ); + await repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: eventId, + accountId: 'microsoft-account', + sourceId: 'microsoft-account|microsoft|ms-cal-1', + providerCalendarId: 'ms-cal-1', + title: 'Copied meeting', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + attendees: const [ + EventAttendeeDraft(email: 'me@example.com', self: true), + EventAttendeeDraft( + email: 'guest@example.com', + responseStatus: 'accepted', ), - ), - throwsA(isA()), - ); + ], + conference: const {'conferenceId': 'old-meeting'}, + ), + ); - final event = await (database.select( - database.calendarEvents, - )..where((table) => table.id.equals(eventId))).getSingle(); - expect(event.accountId, 'account'); - expect(event.calendarSourceId, 'account|google|cal-1'); - expect(event.providerCalendarId, 'cal-1'); - expect(event.title, 'Base'); - expect(event.syncStatus, 'synced'); - expect(await database.select(database.pendingOps).get(), isEmpty); - } + final operations = await database.select(database.pendingOps).get(); + final create = operations.singleWhere( + (op) => op.operationType == 'event.create', + ); + final delete = operations.singleWhere( + (op) => op.operationType == 'event.delete', + ); + expect(create.accountId, 'microsoft-account'); + expect(delete.accountId, 'account'); + expect(delete.dependsOnOpId, create.id); + final createRequest = + jsonDecode(create.requestJson) as Map; + final attendees = createRequest[calendarEventAttendeesField] as List; + expect(attendees, hasLength(1)); + expect((attendees.single as Map)['emailAddress'], { + 'address': 'guest@example.com', + }); + expect(createRequest, isNot(contains('conferenceJson'))); + + final original = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle(); + expect(original.isDeleted, isFalse); + final copy = await (database.select( + database.calendarEvents, + )..where((row) => row.accountId.equals('microsoft-account'))).getSingle(); + expect(copy.title, 'Copied meeting'); + expect(copy.conferenceJson, equals(null)); + }, + ); + + test( + 'cross-provider single occurrence is copied without recurrence', + () async { + await _insertMicrosoftAccountAndSource(database); + final repository = CalendarRepository(database: database); + final eventId = await _insertEvent( + database, + providerEventId: 'provider-occurrence', + providerRecurringEventId: 'provider-series', + ); + + await repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: eventId, + providerRecurringEventId: 'provider-series', + recurringMutationScope: RecurringEventMutationScope.singleOccurrence, + accountId: 'microsoft-account', + sourceId: 'microsoft-account|microsoft|ms-cal-1', + providerCalendarId: 'ms-cal-1', + title: 'Copied occurrence', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + recurrence: const ['RRULE:FREQ=WEEKLY;BYHOUR=9'], + ), + ); + + final operations = await database.select(database.pendingOps).get(); + final create = operations.singleWhere( + (op) => op.operationType == 'event.create', + ); + final delete = operations.singleWhere( + (op) => op.operationType == 'event.delete', + ); + final createRequest = + jsonDecode(create.requestJson) as Map; + final deleteRequest = + jsonDecode(delete.requestJson) as Map; + expect(createRequest[calendarEventRecurrenceField], equals(null)); + expect( + deleteRequest[calendarEventRecurringScopeKey], + RecurringEventMutationScope.singleOccurrence.name, + ); }, ); + test('cross-account move deletes only after the copy is confirmed', () async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'destination-account', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'destination-google-account', + credentialKind: 'oauth', + authState: const Value('signed_in'), + grantedScopes: const Value(''), + createdAtUtc: '2026-06-08T00:00:00.000Z', + updatedAtUtc: '2026-06-08T00:00:00.000Z', + ), + ); + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'destination-account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'destination-cal', + summary: 'Destination', + timeZone: 'America/Vancouver', + ), + ); + final eventId = await _insertEvent( + database, + providerEventId: 'provider-event', + ); + await repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: eventId, + accountId: 'destination-account', + sourceId: 'destination-account|google|destination-cal', + providerCalendarId: 'destination-cal', + title: 'Cross-account copy', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + ); + + final sourceReplayBeforeCopy = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + expect(sourceReplayBeforeCopy, 0); + expect(client.calls, isEmpty); + + final copied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'destination-account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + expect(copied, 1); + var deleteOperation = await (database.select( + database.pendingOps, + )..where((row) => row.operationType.equals('event.delete'))).getSingle(); + var deleteRequest = + jsonDecode(deleteOperation.requestJson) as Map; + expect(deleteRequest[calendarEventCopyConfirmedKey], isTrue); + expect(deleteRequest[calendarEventCopyDestinationEventIdKey], isNotEmpty); + + client.calls.clear(); + final deleted = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + expect(deleted, 1); + expect(client.calls, [ + 'getEvent:cal-1:provider-event', + 'deleteEvent:cal-1:provider-event', + ]); + expect( + (await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle()).isDeleted, + isTrue, + ); + }); + test('recurrence edits cannot target an individual occurrence', () async { final eventId = await _insertEvent( database, @@ -418,57 +1024,243 @@ void main() { lastErrorMessage: 'patch', ); - final applied = await CalendarPendingOpsReplayer( + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 1); + expect(client.calls, ['updateCalendar:cal-1:Renamed']); + expect(await database.pendingOpsDao.getOp('op-poisoned'), equals(null)); + }, + ); + + test( + 'event patch calls provider updateEvent with provider_event_id', + () async { + final eventId = await _insertEvent( + database, + providerEventId: 'provider-event', + ); + await _enqueueEventOp( + database, + id: 'op-1', + operation: 'patch', + operationType: 'event.patch', + eventId: eventId, + request: {'title': 'Patched'}, + ); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 1); + expect(client.calls, [ + 'getEvent:cal-1:provider-event', + 'updateEvent:cal-1:provider-event:Patched', + ]); + expect(client.updatedMutations.single.title, 'Patched'); + expect(client.updatedMutations.single.startTimeZone, equals(null)); + expect(client.updatedMutations.single.endTimeZone, equals(null)); + expect( + await database.pendingOpsDao.pendingOpsForReplay('account', _later), + isEmpty, + ); + }, + ); + + test('entire-series edit patches the recurring master', () async { + final repository = CalendarRepository(database: database); + final ids = []; + for (final day in [1, 8, 15]) { + ids.add(await _insertGoogleOccurrence(repository, day: day)); + } + final detail = await repository.loadEventDetail(ids[1]); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith( + title: 'Renamed series', + start: DateTime(2026, 6, 8, 11), + end: DateTime(2026, 6, 8, 12), + recurringMutationScope: RecurringEventMutationScope.entireSeries, + ), + ); + client.remoteEvent = _googleSeriesMaster(); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 1); + expect(client.calls, [ + 'getEvent:cal-1:series-master', + 'updateEvent:cal-1:series-master:Renamed series', + ]); + expect(client.updatedMutations.single.title, 'Renamed series'); + expect( + client.updatedMutations.single.startDateTime, + '2026-06-01T11:00:00.000', + ); + expect( + client.updatedMutations.single.endDateTime, + '2026-06-01T12:00:00.000', + ); + expect(client.updatedMutations.single.recurrence, const [ + 'RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO;WKST=MO;COUNT=5', + ]); + final rows = await database.select(database.calendarEvents).get(); + expect(rows.map((row) => row.syncStatus), everyElement('synced')); + }); + + test( + 'entire-series retry does not apply the occurrence delta twice', + () async { + final repository = CalendarRepository(database: database); + final ids = []; + for (final day in [1, 8, 15]) { + ids.add(await _insertGoogleOccurrence(repository, day: day)); + } + final detail = await repository.loadEventDetail(ids[1]); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith( + start: DateTime(2026, 6, 8, 11), + end: DateTime(2026, 6, 8, 12), + recurringMutationScope: RecurringEventMutationScope.entireSeries, + ), + ); + client + ..remoteEvent = _googleSeriesMaster() + ..transientUpdateFailures = 1; + + final firstApplied = await CalendarPendingOpsReplayer( database: database, client: client, accountId: 'account', nowUtc: () => DateTime.utc(2026, 6, 8), ).replayDueOps(); - - expect(applied, 1); - expect(client.calls, ['updateCalendar:cal-1:Renamed']); - expect(await database.pendingOpsDao.getOp('op-poisoned'), equals(null)); - }, - ); - - test( - 'event patch calls provider updateEvent with provider_event_id', - () async { - final eventId = await _insertEvent( - database, - providerEventId: 'provider-event', - ); - await _enqueueEventOp( - database, - id: 'op-1', - operation: 'patch', - operationType: 'event.patch', - eventId: eventId, - request: {'title': 'Patched'}, - ); - - final applied = await CalendarPendingOpsReplayer( + final secondApplied = await CalendarPendingOpsReplayer( database: database, client: client, accountId: 'account', - nowUtc: () => DateTime.utc(2026, 6, 8), + nowUtc: () => DateTime.utc(2026, 6, 9), ).replayDueOps(); - expect(applied, 1); - expect(client.calls, [ - 'getEvent:cal-1:provider-event', - 'updateEvent:cal-1:provider-event:Patched', - ]); - expect(client.updatedMutations.single.title, 'Patched'); - expect(client.updatedMutations.single.startTimeZone, 'America/Vancouver'); - expect(client.updatedMutations.single.endTimeZone, 'America/Vancouver'); + expect(firstApplied, 0); + expect(secondApplied, 1); + expect(client.updatedMutations, hasLength(2)); expect( - await database.pendingOpsDao.pendingOpsForReplay('account', _later), - isEmpty, + client.updatedMutations.map((mutation) => mutation.startDateTime), + everyElement('2026-06-01T11:00:00.000'), + ); + expect( + client.updatedMutations.map((mutation) => mutation.endDateTime), + everyElement('2026-06-01T12:00:00.000'), + ); + expect( + client.calls.where((call) => call.startsWith('getEvent:')), + hasLength(1), ); }, ); + test('Google this-and-following edit trims and splits the series', () async { + final repository = CalendarRepository(database: database); + final ids = []; + for (final day in [1, 8, 15]) { + ids.add(await _insertGoogleOccurrence(repository, day: day)); + } + final detail = await repository.loadEventDetail(ids[2]); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith( + title: 'New series title', + recurringMutationScope: RecurringEventMutationScope.thisAndFuture, + ), + ); + client + ..remoteEvent = _googleSeriesMaster() + ..eventInstances = [ + _googleSeriesInstance(day: 1), + _googleSeriesInstance(day: 8), + _googleSeriesInstance(day: 15), + ]; + final operation = await database.select(database.pendingOps).getSingle(); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 1); + expect(client.calls, [ + 'getEvent:cal-1:series-master', + 'listEventInstances:cal-1:series-master', + 'updateEvent:cal-1:series-master:null', + 'createEvent:cal-1:New series title', + ]); + expect(client.updatedMutations.single.recurrence, const [ + 'RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO;WKST=MO;' + 'UNTIL=20260615T085959Z', + ]); + final split = client.createdMutations.single; + expect(split.title, 'New series title'); + expect(split.startDateTime, '2026-06-15T09:00:00.000Z'); + expect(split.endDateTime, '2026-06-15T10:00:00.000Z'); + expect(split.recurrence, const [ + 'RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO;WKST=MO;COUNT=3', + ]); + expect(split.providerEventId, operation.id.replaceAll('-', '')); + expect(split.providerRaw?['id'], 'series-master'); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + test('Google this-and-following delete trims the old series', () async { + final repository = CalendarRepository(database: database); + final ids = []; + for (final day in [1, 8, 15]) { + ids.add(await _insertGoogleOccurrence(repository, day: day)); + } + await repository.deleteLocalEvent( + ids[1], + recurringScope: RecurringEventMutationScope.thisAndFuture, + ); + client.remoteEvent = _googleSeriesMaster(); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 1); + expect(client.calls, [ + 'getEvent:cal-1:series-master', + 'updateEvent:cal-1:series-master:null', + ]); + expect(client.updatedMutations.single.recurrence, const [ + 'RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO;WKST=MO;' + 'UNTIL=20260608T085959Z', + ]); + final rows = + await (database.select(database.calendarEvents)..orderBy([ + (row) => OrderingTerm.asc(row.providerOriginalStartKey), + ])) + .get(); + expect(rows.map((row) => row.isDeleted), [false, true, true]); + expect(rows.map((row) => row.syncStatus), everyElement('synced')); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + test( 'back-to-back local event patches replay in order without self-conflict', () async { @@ -600,6 +1392,149 @@ void main() { }, ); + test( + 'discarding a rebased blocked edit restores the complete remote event', + () async { + final repository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 8), + ); + final baseEvent = client._event( + 'provider-event', + title: 'Base title', + description: 'Planning description', + location: 'Base room', + remindersJson: const { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 30}, + ], + }, + organizerJson: const {'self': true}, + ); + await repository.upsertEvent(accountId: 'account', event: baseEvent); + final eventId = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'provider-event', + ); + + var detail = await repository.loadEventDetail(eventId); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + detail!, + ).copyWith(title: 'First local title'), + ); + detail = await repository.loadEventDetail(eventId); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + detail!, + ).copyWith(location: 'Second local room'), + ); + + client + ..persistEventUpdates = true + ..remoteEvent = client._event( + 'provider-event', + title: 'Base title', + description: 'Planning description', + location: 'Remote room', + remindersJson: baseEvent.remindersJson, + organizerJson: baseEvent.organizerJson, + updatedAtServer: '2026-06-08T00:05:00.000Z', + ); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8, 1), + ).replayDueOps(); + + expect(applied, 1); + final blocked = await database.select(database.pendingOps).getSingle(); + expect(blocked.lastErrorCode, 'conflict'); + expect( + (jsonDecode(blocked.baselineRawJson!) as Map).containsKey( + calendarEventSemanticBaselineKey, + ), + isTrue, + ); + client.getEventError = StateError('Provider temporarily unavailable'); + final failingResolutionService = PendingOpResolutionService( + database: database, + calendarClient: client, + accountId: 'account', + syncTasks: () async {}, + syncCalendar: () async {}, + nowUtc: () => DateTime.utc(2026, 6, 8), + ); + await expectLater( + failingResolutionService.discard(blocked.id), + throwsA(isA()), + ); + expect( + await database.pendingOpsDao.getOp(blocked.id), + isNot(equals(null)), + ); + expect( + (await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle()).location, + 'Second local room', + ); + client.getEventError = null; + final getCallsBeforeDiscard = client.calls + .where((call) => call == 'getEvent:cal-1:provider-event') + .length; + var calendarSyncCalls = 0; + final resolutionService = PendingOpResolutionService( + database: database, + calendarClient: client, + accountId: 'account', + syncTasks: () async {}, + syncCalendar: () async => calendarSyncCalls += 1, + nowUtc: () => DateTime.utc(2026, 6, 8), + ); + + await resolutionService.discard(blocked.id); + + expect( + client.calls + .where((call) => call == 'getEvent:cal-1:provider-event') + .length, + getCallsBeforeDiscard + 1, + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(calendarSyncCalls, 1); + final restored = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle(); + expect(restored.title, 'First local title'); + expect(restored.description, 'Planning description'); + expect(restored.location, 'Remote room'); + expect(restored.startDateTime, '2026-06-08T09:00:00.000Z'); + expect(restored.endDateTime, '2026-06-08T10:00:00.000Z'); + expect(restored.startTimeZone, 'UTC'); + expect(restored.endTimeZone, 'UTC'); + expect(restored.remindersJson, jsonEncode(baseEvent.remindersJson)); + expect(restored.organizerJson, jsonEncode(baseEvent.organizerJson)); + expect(restored.syncStatus, 'synced'); + expect(restored.isDeleted, isFalse); + + final notification = await database + .select(database.notificationSchedule) + .getSingle(); + expect(notification.sourceId, eventId); + expect(notification.title, 'First local title'); + expect( + notification.scheduledAtUtc, + DateTime.utc(2026, 6, 8, 8, 30).millisecondsSinceEpoch, + ); + }, + ); + test('event delete pending op calls provider deleteEvent', () async { final eventId = await _insertEvent( database, @@ -632,6 +1567,33 @@ void main() { expect(row.isDeleted, isTrue); }); + test('entire-series delete marks the master and every occurrence', () async { + final repository = CalendarRepository(database: database); + await repository.upsertEvent( + accountId: 'account', + event: _googleSeriesMaster(), + ); + final occurrenceId = await _insertGoogleOccurrence(repository, day: 8); + await _insertGoogleOccurrence(repository, day: 15); + + await repository.deleteLocalEvent( + occurrenceId, + recurringScope: RecurringEventMutationScope.entireSeries, + ); + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(client.calls, ['deleteEvent:cal-1:series-master']); + final rows = await database.select(database.calendarEvents).get(); + expect(rows, hasLength(3)); + expect(rows.map((row) => row.isDeleted), everyElement(isTrue)); + expect(rows.map((row) => row.syncStatus), everyElement('synced')); + }); + test('provider missing delete is treated as success', () async { client.deleteError = const GoogleCalendarApiError( statusCode: 404, @@ -672,30 +1634,201 @@ void main() { providerEventId: 'provider-event', ); - final accountId = await CalendarRepository( - database: database, - ).deleteLocalEvent(eventId); + final accountId = await CalendarRepository( + database: database, + ).deleteLocalEvent(eventId); + + expect(accountId, 'account'); + final pending = await database.pendingOpsDao.pendingOpsForReplay( + 'account', + _later, + ); + expect(pending.single.operationType, 'event.delete'); + }); + + test('calendar patch pending op calls provider updateCalendar', () async { + await database.pendingOpsDao.enqueue( + PendingOpsCompanion.insert( + id: 'op-calendar-patch', + accountId: 'account', + provider: const Value('google'), + entityType: 'calendar', + operation: 'patch', + operationType: const Value('calendar.patch'), + calendarSourceId: const Value('account|google|cal-1'), + providerCalendarId: const Value('cal-1'), + requestJson: '{"summary":"Renamed"}', + createdAtUtc: '2026-06-08T00:00:00.000Z', + updatedAtUtc: '2026-06-08T00:00:00.000Z', + ), + ); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 1); + expect(client.calls, ['updateCalendar:cal-1:Renamed']); + expect( + await database.pendingOpsDao.pendingOpsForReplay('account', _later), + isEmpty, + ); + }); + + test( + 'calendar patch edited in flight remains queued and is replayed', + () async { + final repository = CalendarRepository(database: database); + const sourceId = 'account|google|cal-1'; + await repository.renameLocalSource(sourceId, 'First rename'); + final patchGate = Completer(); + client.calendarPatchGate = patchGate; + + CalendarPendingOpsReplayer replayer() => CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ); + + final firstReplay = replayer().replayDueOps(); + await _waitFor( + () => client.calls.contains('updateCalendar:cal-1:First rename'), + ); + + await repository.renameLocalSource(sourceId, 'Second rename'); + patchGate.complete(); + + expect(await firstReplay, 1); + final pendingAfterFirst = await database.pendingOpsDao + .pendingOpsForReplay('account', _later); + expect(pendingAfterFirst, hasLength(1)); + expect( + jsonDecode(pendingAfterFirst.single.requestJson), + containsPair('summary', 'Second rename'), + ); + expect( + (await database.select(database.calendarSources).getSingle()).summary, + 'Second rename', + ); + + client.calendarPatchGate = null; + expect(await replayer().replayDueOps(), 1); + + expect(client.calls, [ + 'updateCalendar:cal-1:First rename', + 'updateCalendar:cal-1:Second rename', + ]); + expect( + await database.pendingOpsDao.pendingOpsForReplay('account', _later), + isEmpty, + ); + expect( + (await database.select(database.calendarSources).getSingle()).summary, + 'Second rename', + ); + }, + ); + + test( + 'personal Google calendar patch updates the CalendarList entry', + () async { + await database.pendingOpsDao.enqueue( + PendingOpsCompanion.insert( + id: 'op-calendar-personal-patch', + accountId: 'account', + provider: const Value('google'), + entityType: 'calendar', + operation: 'patch', + operationType: const Value('calendar.patch'), + calendarSourceId: const Value('account|google|cal-1'), + providerCalendarId: const Value('cal-1'), + requestJson: + '{"summary":"My work","_calendarMutationScope":"personal"}', + createdAtUtc: '2026-06-08T00:00:00.000Z', + updatedAtUtc: '2026-06-08T00:00:00.000Z', + ), + ); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 1); + expect(client.calls, ['updateCalendarListEntry:cal-1:My work']); + }, + ); + + test( + 'calendar create remaps dependent event work to the server id', + () async { + final repository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 8), + ); + final temporarySourceId = await repository.createLocalSource( + accountId: 'account', + summary: 'Project', + ); + final temporarySource = await (database.select( + database.calendarSources, + )..where((row) => row.id.equals(temporarySourceId))).getSingle(); + await repository.createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: temporarySourceId, + providerCalendarId: temporarySource.providerCalendarId, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith(title: 'Planning'), + ); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); - expect(accountId, 'account'); - final pending = await database.pendingOpsDao.pendingOpsForReplay( - 'account', - _later, - ); - expect(pending.single.operationType, 'event.delete'); - }); + expect(applied, 2); + expect(client.calls, [ + 'createCalendar:Project', + 'createEvent:cal-created:Planning', + ]); + final sources = await database.select(database.calendarSources).get(); + expect( + sources.map((source) => source.providerCalendarId), + containsAll(['cal-1', 'cal-created']), + ); + expect(sources.any((source) => source.id == temporarySourceId), isFalse); + final event = await database.select(database.calendarEvents).getSingle(); + expect(event.providerCalendarId, 'cal-created'); + expect(event.calendarSourceId, 'account|google|cal-created'); + expect( + await database.pendingOpsDao.pendingOpsForReplay('account', _later), + isEmpty, + ); + }, + ); - test('calendar patch pending op calls provider updateCalendar', () async { + test('calendar delete pending op calls provider deleteCalendar', () async { await database.pendingOpsDao.enqueue( PendingOpsCompanion.insert( - id: 'op-calendar-patch', + id: 'op-calendar-delete', accountId: 'account', provider: const Value('google'), entityType: 'calendar', - operation: 'patch', - operationType: const Value('calendar.patch'), + operation: 'delete', + operationType: const Value('calendar.delete'), calendarSourceId: const Value('account|google|cal-1'), providerCalendarId: const Value('cal-1'), - requestJson: '{"summary":"Renamed"}', + requestJson: '{}', createdAtUtc: '2026-06-08T00:00:00.000Z', updatedAtUtc: '2026-06-08T00:00:00.000Z', ), @@ -708,23 +1841,28 @@ void main() { nowUtc: () => DateTime.utc(2026, 6, 8), ).replayDueOps(); + final source = await (database.select( + database.calendarSources, + )..where((table) => table.id.equals('account|google|cal-1'))).getSingle(); expect(applied, 1); - expect(client.calls, ['updateCalendar:cal-1:Renamed']); - expect( - await database.pendingOpsDao.pendingOpsForReplay('account', _later), - isEmpty, - ); + expect(client.calls, ['deleteCalendar:cal-1']); + expect(source.isDeleted, isTrue); }); - test('calendar delete pending op calls provider deleteCalendar', () async { + test('calendar remove pending op deletes the CalendarList entry', () async { + await (database.update( + database.calendarSources, + )..where((row) => row.id.equals('account|google|cal-1'))).write( + const CalendarSourcesCompanion(dataOwner: Value('other@example.com')), + ); await database.pendingOpsDao.enqueue( PendingOpsCompanion.insert( - id: 'op-calendar-delete', + id: 'op-calendar-remove', accountId: 'account', provider: const Value('google'), entityType: 'calendar', - operation: 'delete', - operationType: const Value('calendar.delete'), + operation: 'remove', + operationType: const Value('calendar.remove'), calendarSourceId: const Value('account|google|cal-1'), providerCalendarId: const Value('cal-1'), requestJson: '{}', @@ -740,14 +1878,177 @@ void main() { nowUtc: () => DateTime.utc(2026, 6, 8), ).replayDueOps(); - final source = await (database.select( - database.calendarSources, - )..where((table) => table.id.equals('account|google|cal-1'))).getSingle(); expect(applied, 1); - expect(client.calls, ['deleteCalendar:cal-1']); - expect(source.isDeleted, isTrue); + expect(client.calls, ['deleteCalendarListEntry:cal-1']); + expect( + (await database.select(database.calendarSources).getSingle()).isDeleted, + isTrue, + ); }); + test( + 'calendar color edited in flight remains queued and is replayed', + () async { + final repository = CalendarRepository(database: database); + const sourceId = 'account|google|cal-1'; + final firstColor = googleCalendarColorChoices[0]; + final secondColor = googleCalendarColorChoices[1]; + await repository.setSourceColor(sourceId, firstColor); + final patchGate = Completer(); + client.calendarPatchGate = patchGate; + + CalendarPendingOpsReplayer replayer() => CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ); + + final firstReplay = replayer().replayDueOps(); + await _waitFor(() => client.calendarMutations.length == 1); + + await repository.setSourceColor(sourceId, secondColor); + patchGate.complete(); + + expect(await firstReplay, 1); + final pendingAfterFirst = await database.pendingOpsDao + .pendingOpsForReplay('account', _later); + expect(pendingAfterFirst, hasLength(1)); + expect( + jsonDecode(pendingAfterFirst.single.requestJson), + containsPair('backgroundColor', secondColor.backgroundColor), + ); + expect( + (await database.select(database.calendarSources).getSingle()) + .backgroundColor, + secondColor.backgroundColor, + ); + + client.calendarPatchGate = null; + expect(await replayer().replayDueOps(), 1); + + expect( + client.calendarMutations.map((mutation) => mutation.backgroundColor), + [firstColor.backgroundColor, secondColor.backgroundColor], + ); + expect( + await database.pendingOpsDao.pendingOpsForReplay('account', _later), + isEmpty, + ); + expect( + (await database.select(database.calendarSources).getSingle()) + .backgroundColor, + secondColor.backgroundColor, + ); + }, + ); + + test( + 'Google calendar delete rejected with 403 restores the source', + () async { + await (database.update(database.calendarSources) + ..where((row) => row.id.equals('account|google|cal-1'))) + .write(const CalendarSourcesCompanion(hidden: Value(true))); + await CalendarRepository( + database: database, + ).deleteLocalSource('account|google|cal-1'); + client.calendarDeleteError = const GoogleCalendarApiError( + statusCode: 403, + code: 'forbidden', + message: 'Permission denied', + ); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + final source = await database + .select(database.calendarSources) + .getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + expect(applied, 0); + expect(source.isDeleted, isFalse); + expect(source.hidden, isTrue); + expect(operation.nextAttemptAtUtc, startsWith('9999-12-31')); + }, + ); + + test( + 'Google CalendarList removal rejected with 403 restores the source', + () async { + await (database.update( + database.calendarSources, + )..where((row) => row.id.equals('account|google|cal-1'))).write( + const CalendarSourcesCompanion(dataOwner: Value('other@example.com')), + ); + await CalendarRepository( + database: database, + ).deleteLocalSource('account|google|cal-1'); + client.calendarListDeleteError = const GoogleCalendarApiError( + statusCode: 403, + code: 'forbidden', + message: 'Permission denied', + ); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + final source = await database + .select(database.calendarSources) + .getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + expect(applied, 0); + expect(source.isDeleted, isFalse); + expect(source.hidden, isFalse); + expect(operation.nextAttemptAtUtc, startsWith('9999-12-31')); + }, + ); + + for (final statusCode in [400, 403]) { + test( + 'Microsoft calendar delete rejected with $statusCode restores the source', + () async { + await _insertMicrosoftAccountAndSource(database, isRemovable: true); + const sourceId = 'microsoft-account|microsoft|ms-cal-1'; + await CalendarRepository( + database: database, + ).deleteLocalSource(sourceId); + final microsoftClient = _FakeMicrosoftCalendarClient() + ..calendarDeleteError = MicrosoftCalendarApiError( + statusCode: statusCode, + code: 'ErrorAccessDenied', + message: 'Permission denied', + ); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: microsoftClient, + accountId: 'microsoft-account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + final source = await (database.select( + database.calendarSources, + )..where((row) => row.id.equals(sourceId))).getSingle(); + final operation = + await (database.select(database.pendingOps) + ..where((row) => row.accountId.equals('microsoft-account'))) + .getSingle(); + expect(applied, 0); + expect(source.isDeleted, isFalse); + expect(source.hidden, isFalse); + expect(operation.nextAttemptAtUtc, startsWith('9999-12-31')); + }, + ); + } + test('sync engine replays pending event ops before pull sync', () async { await CalendarRepository(database: database).createLocalEvent( EventEditorDraft.newEvent( @@ -940,12 +2241,62 @@ Future _insertAccount(AppDatabase database) { authority: 'https://accounts.google.com', providerAccountId: 'google-account', credentialKind: 'oauth', + email: const Value('me@example.com'), + authState: const Value('signed_in'), + grantedScopes: const Value(''), + createdAtUtc: '2026-06-08T00:00:00.000Z', + updatedAtUtc: '2026-06-08T00:00:00.000Z', + ), + ); +} + +Future _insertDestinationGoogleAccount(AppDatabase database) { + return database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'destination-account', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'destination-google-account', + credentialKind: 'oauth', + authState: const Value('signed_in'), + grantedScopes: const Value(''), + createdAtUtc: '2026-06-08T00:00:00.000Z', + updatedAtUtc: '2026-06-08T00:00:00.000Z', + ), + ); +} + +Future _insertMicrosoftAccountAndSource( + AppDatabase database, { + bool? isRemovable, +}) async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'microsoft-account', + provider: 'microsoft', + authority: 'https://login.microsoftonline.com/common', + providerAccountId: 'microsoft-account', + 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 CalendarRepository(database: database).upsertSource( + accountId: 'microsoft-account', + source: CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'ms-cal-1', + summary: 'Outlook', + timeZone: 'America/Vancouver', + isRemovable: isRemovable, + ), + ); } Future _insertEvent( @@ -961,6 +2312,7 @@ Future _insertEvent( providerEventId: providerEventId, providerRecurringEventId: providerRecurringEventId, title: 'Base', + organizerJson: const {'self': true}, startDateTime: '2026-06-08T09:00:00.000Z', startTimeZone: startTimeZone, endDateTime: '2026-06-08T10:00:00.000Z', @@ -983,6 +2335,102 @@ Future _insertEvent( ); } +Future _insertGoogleOccurrence( + CalendarRepository repository, { + required int day, +}) async { + final date = day.toString().padLeft(2, '0'); + final start = '2026-06-${date}T09:00:00.000Z'; + final end = '2026-06-${date}T10:00:00.000Z'; + final providerEventId = 'occurrence-$date'; + await repository.upsertEvent( + accountId: 'account', + event: CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: providerEventId, + providerRecurringEventId: 'series-master', + providerOriginalStartKey: start, + title: 'Base', + organizerJson: const {'self': true}, + startDateTime: start, + startTimeZone: 'UTC', + endDateTime: end, + endTimeZone: 'UTC', + updatedAtServer: '2026-05-30T00:00:00.000Z', + rawJson: { + 'id': providerEventId, + 'summary': 'Base', + 'recurringEventId': 'series-master', + 'originalStartTime': {'dateTime': start}, + 'start': {'dateTime': start, 'timeZone': 'UTC'}, + 'end': {'dateTime': end, 'timeZone': 'UTC'}, + 'updated': '2026-05-30T00:00:00.000Z', + }, + ), + ); + return CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: providerEventId, + providerOriginalStartKey: start, + ); +} + +CalendarEventDto _googleSeriesMaster() { + const start = '2026-06-01T09:00:00.000Z'; + const end = '2026-06-01T10:00:00.000Z'; + const recurrence = ['RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO;COUNT=5']; + return const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'series-master', + title: 'Base', + organizerJson: {'self': true}, + startDateTime: start, + startTimeZone: 'UTC', + endDateTime: end, + endTimeZone: 'UTC', + recurrenceJson: recurrence, + updatedAtServer: '2026-05-30T00:00:00.000Z', + rawJson: { + 'id': 'series-master', + 'summary': 'Base', + 'start': {'dateTime': start, 'timeZone': 'UTC'}, + 'end': {'dateTime': end, 'timeZone': 'UTC'}, + 'recurrence': recurrence, + 'updated': '2026-05-30T00:00:00.000Z', + }, + ); +} + +CalendarEventDto _googleSeriesInstance({required int day}) { + final date = day.toString().padLeft(2, '0'); + final start = '2026-06-${date}T09:00:00.000Z'; + final end = '2026-06-${date}T10:00:00.000Z'; + return CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'instance-$date', + providerRecurringEventId: 'series-master', + providerOriginalStartKey: start, + title: 'Base', + organizerJson: const {'self': true}, + startDateTime: start, + startTimeZone: 'UTC', + endDateTime: end, + endTimeZone: 'UTC', + rawJson: { + 'id': 'instance-$date', + 'recurringEventId': 'series-master', + 'originalStartTime': {'dateTime': start}, + 'start': {'dateTime': start, 'timeZone': 'UTC'}, + 'end': {'dateTime': end, 'timeZone': 'UTC'}, + }, + ); +} + Future _enqueueEventOp( AppDatabase database, { required String id, @@ -1019,19 +2467,31 @@ Future _enqueueEventOp( final _later = DateTime.utc(2026, 6, 9); -class _FakeCalendarClient implements CloudCalendarClient { +class _FakeCalendarClient + implements CloudCalendarClient, CalendarListManagementClient { final calls = []; final createdMutations = []; final updatedMutations = []; + final calendarMutations = []; final guestUpdatePolicies = []; final invitationResponses = []; + final Map _createdEventsByIdentity = {}; int _createdCount = 0; int transientUpdateFailures = 0; CalendarEventDto? syncEvent; CalendarEventDto? remoteEvent; + List eventInstances = const []; bool persistEventUpdates = false; int _eventUpdateRevision = 0; GoogleCalendarApiError? deleteError; + Object? calendarDeleteError; + GoogleCalendarApiError? calendarListDeleteError; + Completer? createEventGate; + Completer? calendarPatchGate; + Object? createEventResponseError; + Object? getEventError; + + int get distinctCreatedEventCount => _createdEventsByIdentity.length; @override BusyProvider get provider => BusyProvider.google; @@ -1062,13 +2522,40 @@ class _FakeCalendarClient implements CloudCalendarClient { calls.add('createEvent:$calendarId:${mutation.title}'); createdMutations.add(mutation); guestUpdatePolicies.add(guestUpdatePolicy); + await createEventGate?.future; + final identity = provider == BusyProvider.google + ? mutation.providerEventId + : mutation.transactionId; + final existing = identity == null + ? null + : _createdEventsByIdentity[identity]; + if (existing != null) { + if (provider == BusyProvider.google) { + throw const GoogleCalendarApiError( + statusCode: 409, + code: 'ALREADY_EXISTS', + message: 'The requested identifier already exists.', + ); + } + return existing; + } _createdCount += 1; - return _event( - 'server-event-$_createdCount', + final event = _event( + provider == BusyProvider.google && identity != null + ? identity + : 'server-event-$_createdCount', title: mutation.title ?? '', + providerCalendarId: calendarId, startTimeZone: mutation.startTimeZone, endTimeZone: mutation.endTimeZone, ); + if (identity != null) { + _createdEventsByIdentity[identity] = event; + } + final responseError = createEventResponseError; + createEventResponseError = null; + if (responseError != null) throw responseError; + return event; } @override @@ -1096,9 +2583,16 @@ class _FakeCalendarClient implements CloudCalendarClient { title: persistEventUpdates ? mutation.title ?? current?.title ?? '' : mutation.title ?? '', + description: persistEventUpdates + ? mutation.description ?? current?.description + : mutation.description, location: persistEventUpdates ? mutation.location ?? current?.location : mutation.location, + remindersJson: persistEventUpdates + ? mutation.reminders ?? current?.remindersJson + : mutation.reminders, + organizerJson: persistEventUpdates ? current?.organizerJson : null, updatedAtServer: persistEventUpdates ? DateTime.utc( 2026, @@ -1110,6 +2604,7 @@ class _FakeCalendarClient implements CloudCalendarClient { : '2026-06-08T00:00:00.000Z', startTimeZone: mutation.startTimeZone, endTimeZone: mutation.endTimeZone, + providerCalendarId: calendarId, ); if (persistEventUpdates) { remoteEvent = event; @@ -1123,7 +2618,14 @@ class _FakeCalendarClient implements CloudCalendarClient { required String eventId, }) async { calls.add('getEvent:$calendarId:$eventId'); - return remoteEvent ?? _event(eventId, title: 'Base'); + final error = getEventError; + if (error != null) throw error; + final remote = remoteEvent; + if (remote != null) return remote; + for (final created in _createdEventsByIdentity.values) { + if (created.providerEventId == eventId) return created; + } + return _event(eventId, title: 'Base'); } @override @@ -1141,6 +2643,23 @@ class _FakeCalendarClient implements CloudCalendarClient { } } + @override + Future moveEvent({ + required String sourceCalendarId, + required String eventId, + required String destinationCalendarId, + CalendarGuestUpdatePolicy guestUpdatePolicy = + CalendarGuestUpdatePolicy.send, + }) async { + calls.add('moveEvent:$sourceCalendarId:$eventId:$destinationCalendarId'); + guestUpdatePolicies.add(guestUpdatePolicy); + return _event( + eventId, + title: remoteEvent?.title ?? 'Base', + providerCalendarId: destinationCalendarId, + ); + } + @override Future respondToEvent({ required String calendarId, @@ -1175,29 +2694,47 @@ class _FakeCalendarClient implements CloudCalendarClient { CalendarEventDto _event( String id, { required String title, + String providerCalendarId = 'cal-1', + String? description, String? location, + Object? remindersJson, + Object? organizerJson, String? etagOrChangeKey, String updatedAtServer = '2026-06-08T00:00:00.000Z', String? startTimeZone, String? endTimeZone, }) { return CalendarEventDto( - provider: BusyProvider.google, - providerCalendarId: 'cal-1', + provider: provider, + providerCalendarId: providerCalendarId, providerEventId: id, etagOrChangeKey: etagOrChangeKey, title: title, + description: description, location: location, startDateTime: '2026-06-08T09:00:00.000Z', - startTimeZone: startTimeZone, + startTimeZone: startTimeZone ?? 'UTC', endDateTime: '2026-06-08T10:00:00.000Z', - endTimeZone: endTimeZone, + endTimeZone: endTimeZone ?? 'UTC', + remindersJson: remindersJson, + organizerJson: organizerJson, updatedAtServer: updatedAtServer, rawJson: { 'id': id, 'summary': title, + if (description != null) 'description': description, if (location != null) 'location': location, if (etagOrChangeKey != null) 'etag': etagOrChangeKey, + 'start': { + 'dateTime': '2026-06-08T09:00:00.000Z', + 'timeZone': startTimeZone ?? 'UTC', + }, + 'end': { + 'dateTime': '2026-06-08T10:00:00.000Z', + 'timeZone': endTimeZone ?? 'UTC', + }, + if (remindersJson != null) 'reminders': remindersJson, + if (organizerJson != null) 'organizer': organizerJson, 'updated': updatedAtServer, }, ); @@ -1216,6 +2753,15 @@ class _FakeCalendarClient implements CloudCalendarClient { @override Future deleteCalendar(String calendarId) async { calls.add('deleteCalendar:$calendarId'); + final error = calendarDeleteError; + if (error != null) throw error; + } + + @override + Future deleteCalendarListEntry(String calendarId) async { + calls.add('deleteCalendarListEntry:$calendarId'); + final error = calendarListDeleteError; + if (error != null) throw error; } @override @@ -1233,8 +2779,9 @@ class _FakeCalendarClient implements CloudCalendarClient { required String recurringEventId, required DateTime rangeStart, required DateTime rangeEnd, - }) { - throw UnimplementedError(); + }) async { + calls.add('listEventInstances:$calendarId:$recurringEventId'); + return eventInstances; } @override @@ -1253,10 +2800,47 @@ class _FakeCalendarClient implements CloudCalendarClient { CalendarMutation mutation, ) async { calls.add('updateCalendar:$calendarId:${mutation.summary}'); + calendarMutations.add(mutation); + await calendarPatchGate?.future; + return CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: calendarId, + summary: mutation.summary ?? 'Calendar', + dataOwner: 'me@example.com', + ); + } + + @override + Future updateCalendarListEntry( + String calendarId, + CalendarMutation mutation, + ) async { + calls.add('updateCalendarListEntry:$calendarId:${mutation.summary}'); + calendarMutations.add(mutation); + await calendarPatchGate?.future; return CalendarSourceDto( provider: BusyProvider.google, providerCalendarId: calendarId, summary: mutation.summary ?? 'Calendar', + dataOwner: 'me@example.com', ); } } + +class _FakeMicrosoftCalendarClient extends _FakeCalendarClient { + @override + BusyProvider get provider => BusyProvider.microsoft; + + @override + CalendarProviderCapabilities get capabilities => + microsoftCalendarProviderCapabilities; +} + +Future _waitFor(bool Function() condition) async { + final deadline = DateTime.now().add(const Duration(seconds: 2)); + while (DateTime.now().isBefore(deadline)) { + if (condition()) return; + await Future.delayed(const Duration(milliseconds: 10)); + } + fail('Timed out waiting for condition.'); +} diff --git a/test/features/sync/calendar_sync_engine_test.dart b/test/features/sync/calendar_sync_engine_test.dart index d36295e7..1f39ebcf 100644 --- a/test/features/sync/calendar_sync_engine_test.dart +++ b/test/features/sync/calendar_sync_engine_test.dart @@ -4,6 +4,7 @@ import 'package:busymax/src/calendar_providers/calendar_sync_dto.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/notifications/notification_schedule_service.dart'; import 'package:busymax/src/features/sync/calendar_sync_engine.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:drift/drift.dart'; @@ -401,6 +402,118 @@ void main() { expect(event.isDeleted, isFalse); }); + test( + 'Microsoft full sync tombstones calendars absent from the collection', + () async { + const source = CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-deleted', + summary: 'Deleted in Outlook', + ); + await _insertAccount(database, provider: BusyProvider.microsoft); + await _insertSource(database, source); + final eventId = await _insertEvent( + database, + provider: BusyProvider.microsoft, + providerCalendarId: source.providerCalendarId, + remindersJson: const { + 'isReminderOn': true, + 'reminderMinutesBeforeStart': 30, + }, + ); + final notificationService = NotificationScheduleService( + database: database, + nowUtc: () => DateTime.utc(2026, 7, 10), + ); + await notificationService.rebuildUpcomingEventNotifications('account'); + expect( + await database.select(database.notificationSchedule).get(), + hasLength(1), + ); + + await CalendarSyncEngine( + database: database, + client: _FakeCalendarClient( + provider: BusyProvider.microsoft, + calendars: const [], + pages: const [], + ), + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 7, 10), + ).fullSync(); + + final storedSource = + await (database.select( + database.calendarSources, + )..where((row) => row.id.equals('account|microsoft|cal-deleted'))) + .getSingle(); + final storedEvent = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle(); + expect(storedSource.isDeleted, isTrue); + expect(storedSource.hidden, isTrue); + expect(storedEvent.isDeleted, isTrue); + expect( + await database.select(database.notificationSchedule).get(), + isEmpty, + ); + }, + ); + + test( + 'Microsoft incremental reconciliation preserves a queued calendar create', + () async { + const missingSource = CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-deleted', + summary: 'Deleted in Outlook', + ); + await _insertAccount(database, provider: BusyProvider.microsoft); + await _insertSource(database, missingSource); + final repository = CalendarRepository(database: database); + final localSourceId = await repository.createLocalSource( + accountId: 'account', + summary: 'Offline calendar', + ); + await (database.update( + database.pendingOps, + )..where((row) => row.calendarSourceId.equals(localSourceId))).write( + const PendingOpsCompanion( + nextAttemptAtUtc: Value('9999-12-31T00:00:00.000Z'), + ), + ); + + await CalendarSyncEngine( + database: database, + client: _FakeCalendarClient( + provider: BusyProvider.microsoft, + calendars: const [], + pages: const [], + ), + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 7, 10), + ).incrementalSync(); + + final sources = await database.select(database.calendarSources).get(); + expect( + sources.singleWhere((source) => source.id == localSourceId).isDeleted, + isFalse, + ); + expect( + sources + .singleWhere((source) => source.providerCalendarId == 'cal-deleted') + .isDeleted, + isTrue, + ); + expect( + await (database.select( + database.pendingOps, + )..where((row) => row.calendarSourceId.equals(localSourceId))).get(), + hasLength(1), + ); + }, + ); + test( 'Microsoft primary sync persists and reuses the terminal delta link', () async { @@ -564,10 +677,12 @@ Future _insertEvent( AppDatabase database, { required BusyProvider provider, required String providerCalendarId, + Object? remindersJson, }) async { final event = _event( provider: provider, providerCalendarId: providerCalendarId, + remindersJson: remindersJson, ); await CalendarRepository( database: database, @@ -583,6 +698,7 @@ Future _insertEvent( CalendarEventDto _event({ required BusyProvider provider, required String providerCalendarId, + Object? remindersJson, }) { return CalendarEventDto( provider: provider, @@ -591,6 +707,7 @@ CalendarEventDto _event({ title: 'Planning', startDateTime: '2026-07-15T09:00:00.000Z', endDateTime: '2026-07-15T10:00:00.000Z', + remindersJson: remindersJson, updatedAtServer: '2026-07-01T00:00:00.000Z', rawJson: const { 'id': 'event-1', @@ -697,6 +814,17 @@ class _FakeCalendarClient implements CloudCalendarClient { throw UnimplementedError(); } + @override + Future moveEvent({ + required String sourceCalendarId, + required String eventId, + required String destinationCalendarId, + CalendarGuestUpdatePolicy guestUpdatePolicy = + CalendarGuestUpdatePolicy.send, + }) { + throw UnimplementedError(); + } + @override Future getEvent({ required String calendarId, diff --git a/test/features/sync/pending_op_resolution_service_test.dart b/test/features/sync/pending_op_resolution_service_test.dart index 61db0651..3c011d7e 100644 --- a/test/features/sync/pending_op_resolution_service_test.dart +++ b/test/features/sync/pending_op_resolution_service_test.dart @@ -1,30 +1,37 @@ import 'dart:convert'; +import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; +import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; import 'package:busymax/src/features/sync/pending_op_resolution_service.dart'; -import 'package:busymax/src/features/sync/sync_engine.dart'; import 'package:busymax/src/features/tasks/domain/task_remote_client.dart'; import 'package:busymax/src/google_tasks/api/google_tasks_api_error.dart'; import 'package:busymax/src/features/tasks/domain/task_remote_models.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; void main() { late AppDatabase database; late _FakeTaskRemoteClient apiClient; - late _FakeSyncEngine syncEngine; late PendingOpResolutionService service; + late int taskSyncCalls; + late int calendarSyncCalls; setUp(() async { database = AppDatabase(NativeDatabase.memory()); apiClient = _FakeTaskRemoteClient(); - syncEngine = _FakeSyncEngine(); + taskSyncCalls = 0; + calendarSyncCalls = 0; service = PendingOpResolutionService( database: database, apiClient: apiClient, accountId: 'account', - syncEngine: syncEngine, + syncTasks: () async => taskSyncCalls += 1, + syncCalendar: () async => calendarSyncCalls += 1, nowUtc: () => DateTime.utc(2026, 6, 4), ); @@ -61,7 +68,7 @@ void main() { expect(task.pendingDelete, isFalse); expect(task.pendingMove, isFalse); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); - expect(syncEngine.incrementalSyncCalls, 1); + expect(taskSyncCalls, 1); }, ); @@ -89,7 +96,7 @@ void main() { expect(task.localDirty, isFalse); expect(task.pendingDelete, isFalse); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); - expect(syncEngine.incrementalSyncCalls, 1); + expect(taskSyncCalls, 1); }, ); @@ -114,7 +121,7 @@ void main() { expect(await database.tasksDao.listTasks('account', 'list-1'), isEmpty); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); - expect(syncEngine.incrementalSyncCalls, 1); + expect(taskSyncCalls, 1); }, ); @@ -141,7 +148,7 @@ void main() { expect(list.localDirty, isFalse); expect(list.pendingDelete, isFalse); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); - expect(syncEngine.incrementalSyncCalls, 1); + expect(taskSyncCalls, 1); }, ); @@ -171,7 +178,7 @@ void main() { expect(await database.taskListsDao.listTaskLists('account'), isEmpty); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); - expect(syncEngine.incrementalSyncCalls, 1); + expect(taskSyncCalls, 1); }, ); @@ -203,7 +210,7 @@ void main() { )).single; expect(task.localDirty, isTrue); expect(await database.pendingOpsDao.getOp('op-1'), isNot(equals(null))); - expect(syncEngine.incrementalSyncCalls, 0); + expect(taskSyncCalls, 0); }, ); @@ -234,7 +241,7 @@ void main() { )).single; expect(list.localDirty, isTrue); expect(await database.pendingOpsDao.getOp('op-1'), isNot(equals(null))); - expect(syncEngine.incrementalSyncCalls, 0); + expect(taskSyncCalls, 0); }, ); @@ -274,7 +281,7 @@ void main() { expect(sourceTasks.single.pendingMove, isFalse); expect(destinationTasks, isEmpty); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); - expect(syncEngine.incrementalSyncCalls, 1); + expect(taskSyncCalls, 1); }, ); @@ -310,7 +317,7 @@ void main() { expect(await database.tasksDao.listTasks('account', 'list-1'), isEmpty); expect(await database.tasksDao.listTasks('account', 'list-2'), isEmpty); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); - expect(syncEngine.incrementalSyncCalls, 1); + expect(taskSyncCalls, 1); }, ); @@ -337,8 +344,269 @@ void main() { expect(task.localDirty, isFalse); expect(task.pendingMove, isFalse); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); - expect(syncEngine.incrementalSyncCalls, 1); + expect(taskSyncCalls, 1); }); + + test('discard blocked calendar removal restores the source', () async { + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'calendar-source', + accountId: 'account', + provider: 'google', + providerCalendarId: 'calendar@example.com', + summary: 'Calendar', + hidden: const Value(true), + isDeleted: const Value(true), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + await _enqueueBlockedOp( + database, + entityType: 'calendar', + operation: 'delete', + operationType: 'calendar.delete', + calendarSourceId: 'calendar-source', + request: const {calendarRemovalPreviousHiddenKey: false}, + ); + + await service.discard('op-1'); + + final source = await database.select(database.calendarSources).getSingle(); + expect(source.isDeleted, isFalse); + expect(source.hidden, isFalse); + expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); + expect(calendarSyncCalls, 1); + expect(taskSyncCalls, 0); + }); + + test( + 'discard blocked calendar creation removes its temporary source', + () async { + final repository = _calendarRepository(database); + final sourceId = await repository.createLocalSource( + accountId: 'account', + summary: 'Temporary calendar', + ); + final operation = await database.select(database.pendingOps).getSingle(); + await _blockOperation(database, operation.id); + + await service.discard(operation.id); + + expect(await database.select(database.calendarSources).get(), isEmpty); + expect(await database.select(database.calendarEvents).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(calendarSyncCalls, 0); + expect(taskSyncCalls, 0); + expect(sourceId, isNotEmpty); + }, + ); + + test( + 'discard blocked calendar creation removes dependent events and operations', + () async { + final repository = _calendarRepository(database); + final sourceId = await repository.createLocalSource( + accountId: 'account', + summary: 'Temporary calendar', + ); + final source = await database + .select(database.calendarSources) + .getSingle(); + await repository.createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: sourceId, + providerCalendarId: source.providerCalendarId, + start: DateTime.utc(2026, 6, 5, 9), + end: DateTime.utc(2026, 6, 5, 10), + ).copyWith(title: 'Dependent event'), + ); + final operations = await database.select(database.pendingOps).get(); + final calendarCreate = operations.singleWhere( + (operation) => operation.operationType == 'calendar.create', + ); + final eventCreate = operations.singleWhere( + (operation) => operation.operationType == 'event.create', + ); + expect(eventCreate.dependsOnOpId, calendarCreate.id); + await _blockOperation(database, calendarCreate.id); + + await service.discard(calendarCreate.id); + + expect(await database.select(database.calendarSources).get(), isEmpty); + expect(await database.select(database.calendarEvents).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(calendarSyncCalls, 0); + expect(taskSyncCalls, 0); + }, + ); + + test('discard blocked calendar patch restores optimistic metadata', () async { + final repository = _calendarRepository(database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar@example.com', + summary: 'Provider name', + dataOwner: 'google-account@example.com', + rawJson: {'id': 'calendar@example.com', 'summary': 'Provider name'}, + ), + ); + const sourceId = 'account|google|calendar@example.com'; + await repository.renameLocalSource(sourceId, 'Optimistic name'); + final operation = await database.select(database.pendingOps).getSingle(); + await _blockOperation(database, operation.id); + + await service.discard(operation.id); + + final source = await database.select(database.calendarSources).getSingle(); + expect(source.summary, 'Provider name'); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(calendarSyncCalls, 1); + expect(taskSyncCalls, 0); + }); + + test('discard blocked event creation removes its local projection', () async { + final repository = _calendarRepository(database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar@example.com', + summary: 'Calendar', + ), + ); + final sourceId = CalendarRepository.sourceId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'calendar@example.com', + ); + final operationId = await repository.createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: sourceId, + providerCalendarId: 'calendar@example.com', + start: DateTime.utc(2026, 6, 5, 9), + end: DateTime.utc(2026, 6, 5, 10), + ).copyWith(title: 'Unsent event'), + ); + await _blockOperation(database, operationId); + + await service.discard(operationId); + + expect(await database.select(database.calendarEvents).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(calendarSyncCalls, 0); + expect(taskSyncCalls, 0); + }); + + test('discard blocked event patch restores its provider baseline', () async { + final repository = _calendarRepository(database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar@example.com', + summary: 'Calendar', + ), + ); + await repository.upsertEvent( + accountId: 'account', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar@example.com', + providerEventId: 'event-1', + title: 'Provider title', + startDateTime: '2026-06-05T09:00:00.000Z', + startTimeZone: 'UTC', + endDateTime: '2026-06-05T10:00:00.000Z', + endTimeZone: 'UTC', + organizerJson: {'self': true}, + updatedAtServer: '2026-06-04T00:00:00.000Z', + rawJson: { + 'id': 'event-1', + 'summary': 'Provider title', + 'start': {'dateTime': '2026-06-05T09:00:00.000Z', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-06-05T10:00:00.000Z', 'timeZone': 'UTC'}, + 'organizer': {'self': true}, + 'updated': '2026-06-04T00:00:00.000Z', + }, + ), + ); + final event = await database.select(database.calendarEvents).getSingle(); + final detail = await repository.loadEventDetail(event.id); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + detail!, + ).copyWith(title: 'Optimistic title'), + ); + final operation = await database.select(database.pendingOps).getSingle(); + await _blockOperation(database, operation.id); + + await service.discard(operation.id); + + final restored = await database.select(database.calendarEvents).getSingle(); + expect(restored.title, 'Provider title'); + expect(restored.syncStatus, 'synced'); + expect(restored.isDeleted, isFalse); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(calendarSyncCalls, 1); + expect(taskSyncCalls, 0); + }); + + test( + 'retry routes task synchronization through the shared callback', + () async { + var taskSyncCalls = 0; + final coordinatedService = PendingOpResolutionService( + database: database, + accountId: 'account', + syncTasks: () async => taskSyncCalls += 1, + syncCalendar: () async => calendarSyncCalls += 1, + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await _enqueueBlockedOp( + database, + operation: 'patch_task', + taskListId: 'list-1', + taskId: 'task-1', + ); + + await coordinatedService.retryNow('op-1'); + + expect(taskSyncCalls, 1); + expect(calendarSyncCalls, 0); + }, + ); + + test( + 'retry routes event synchronization through the calendar callback', + () async { + var taskSyncCalls = 0; + final coordinatedService = PendingOpResolutionService( + database: database, + accountId: 'account', + syncTasks: () async => taskSyncCalls += 1, + syncCalendar: () async => calendarSyncCalls += 1, + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await _enqueueBlockedOp( + database, + entityType: 'event', + operation: 'patch', + operationType: 'event.patch', + ); + + await coordinatedService.retryNow('op-1'); + + expect(calendarSyncCalls, 1); + expect(taskSyncCalls, 0); + }, + ); } class _FakeTaskRemoteClient implements TaskRemoteClient { @@ -372,18 +640,6 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -class _FakeSyncEngine implements SyncEngine { - var incrementalSyncCalls = 0; - - @override - Future fullSync() async {} - - @override - Future incrementalSync() async { - incrementalSyncCalls += 1; - } -} - Future _insertAccount(AppDatabase database) { return database .into(database.accounts) @@ -394,12 +650,30 @@ Future _insertAccount(AppDatabase database) { authority: 'https://accounts.google.com', providerAccountId: 'google-account', credentialKind: 'oauth', + email: const Value('google-account@example.com'), createdAtUtc: _now, updatedAtUtc: _now, ), ); } +CalendarRepository _calendarRepository(AppDatabase database) { + return CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 4), + ); +} + +Future _blockOperation(AppDatabase database, String operationId) { + return database.pendingOpsDao.updateAttempt( + id: operationId, + attemptCount: 1, + nextAttemptAtUtc: DateTime.utc(9999, 12, 31), + lastErrorCode: 'provider_rejected', + lastErrorMessage: 'Provider rejected the operation.', + ); +} + TaskListsCompanion _localTaskList( String id, { String title = 'List', @@ -443,8 +717,10 @@ Future _enqueueBlockedOp( AppDatabase database, { String entityType = 'task', required String operation, + String? operationType, String? taskListId, String? taskId, + String? calendarSourceId, Map request = const {}, }) { return database.pendingOpsDao.enqueue( @@ -453,8 +729,10 @@ Future _enqueueBlockedOp( accountId: 'account', entityType: entityType, operation: operation, + operationType: Value(operationType), taskListId: Value(taskListId), taskId: Value(taskId), + calendarSourceId: Value(calendarSourceId), requestJson: jsonEncode(request), nextAttemptAtUtc: const Value('9999-12-31T00:00:00.000Z'), lastErrorCode: const Value('conflict'), diff --git a/test/features/sync/pending_ops_replayer_test.dart b/test/features/sync/pending_ops_replayer_test.dart index efe34bab..6aee3082 100644 --- a/test/features/sync/pending_ops_replayer_test.dart +++ b/test/features/sync/pending_ops_replayer_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:math'; @@ -154,6 +155,265 @@ void main() { expect(lists.map((list) => list.id), isNot(contains('local-tasklist-1'))); }); + test('concurrent replayers dispatch a queued task create once', () async { + await database.tasksDao.upsertTask( + _task('list-1', 'local-task-1', title: 'Draft'), + ); + await _enqueue( + database, + id: '01', + operation: 'create_task', + taskListId: 'list-1', + taskId: 'local-task-1', + localTempId: 'local-task-1', + request: { + 'body': {'title': 'Draft'}, + }, + ); + final createGate = Completer(); + apiClient.createTaskGate = createGate; + + PendingOpsReplayer replayer() => PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + + final first = replayer().replayDueOps(); + await _waitFor(() => apiClient.calls.length == 1); + final second = replayer().replayDueOps(); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(apiClient.calls, ['create_task:list-1']); + + createGate.complete(); + expect(await first, 1); + expect(await second, 0); + expect(apiClient.calls, ['create_task:list-1']); + }); + + test('task edit waits while its creation is retrying', () async { + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Draft'), + ); + final localTask = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + await repository.patchTask( + 'list-1', + localTask.id, + const TaskPatchInput({'title': 'Edited offline'}), + ); + apiClient.createTaskError = const GoogleTasksApiError( + statusCode: 503, + message: 'Temporarily unavailable', + ); + + final firstApplied = await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(firstApplied, 0); + expect(apiClient.calls, ['create_task:list-1']); + var operations = await database.select(database.pendingOps).get(); + final pendingCreate = operations.singleWhere( + (operation) => operation.operation == 'create_task', + ); + final pendingPatch = operations.singleWhere( + (operation) => operation.operation == 'patch_task', + ); + expect(pendingCreate.state, 'retry'); + expect(pendingCreate.attemptCount, 1); + expect(pendingPatch.dependsOnOpId, pendingCreate.id); + expect(pendingPatch.attemptCount, 0); + + apiClient.createTaskError = null; + final secondApplied = await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 5), + ).replayDueOps(); + + expect(secondApplied, 2); + expect(apiClient.calls, [ + 'create_task:list-1', + 'create_task:list-1', + 'patch_task:task-server', + ]); + expect(apiClient.taskPatchFields.single['title'], 'Edited offline'); + operations = await database.select(database.pendingOps).get(); + expect(operations, isEmpty); + }); + + test('deleting a task whose create is in flight queues after it', () async { + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Draft'), + ); + final localTask = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + final gate = Completer(); + apiClient.createTaskGate = gate; + final firstReplay = PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + await _waitFor(() => apiClient.calls.isNotEmpty); + + await repository.deleteTask('list-1', localTask.id); + + var operations = await database.select(database.pendingOps).get(); + final create = operations.singleWhere( + (operation) => operation.operation == 'create_task', + ); + final delete = operations.singleWhere( + (operation) => operation.operation == 'delete_task', + ); + expect(create.state, 'in_progress'); + expect(delete.dependsOnOpId, create.id); + + gate.complete(); + expect(await firstReplay, 1); + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 2), + ).replayDueOps(), + 1, + ); + expect(apiClient.calls, ['create_task:list-1', 'delete_task:task-server']); + operations = await database.select(database.pendingOps).get(); + expect(operations, isEmpty); + }); + + test('server ID rewrite revives a legacy blocked task edit', () async { + await database.tasksDao.upsertTask( + _task('list-1', 'local-task-1', title: 'Draft'), + ); + await _enqueue( + database, + id: '01', + operation: 'create_task', + taskListId: 'list-1', + taskId: 'local-task-1', + localTempId: 'local-task-1', + request: { + 'body': {'title': 'Draft'}, + }, + ); + await _enqueue( + database, + id: '02', + operation: 'patch_task', + taskListId: 'list-1', + taskId: 'local-task-1', + request: {'title': 'Edited offline'}, + ); + await (database.update( + database.pendingOps, + )..where((row) => row.id.equals('02'))).write( + const PendingOpsCompanion( + state: Value('failed'), + attemptCount: Value(1), + nextAttemptAtUtc: Value('9999-12-31T00:00:00.000Z'), + lastErrorCode: Value('400'), + lastErrorMessage: Value('Invalid temporary task ID'), + ), + ); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 1, + ); + + final revived = await database.pendingOpsDao.getOp('02'); + expect(revived!.taskId, 'task-server'); + expect(revived.state, 'pending'); + expect(revived.nextAttemptAtUtc, equals(null)); + expect(revived.lastErrorCode, equals(null)); + expect(revived.lastErrorMessage, equals(null)); + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 2), + ).replayDueOps(), + 1, + ); + expect(apiClient.calls, ['create_task:list-1', 'patch_task:task-server']); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + test('new task can move to another list before creation sync', () async { + await database.taskListsDao.upsertTaskList(_taskList('list-2')); + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Draft'), + ); + final localTask = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + await repository.moveTask( + TaskMoveInput( + sourceTaskListId: 'list-1', + taskId: localTask.id, + destinationTaskListId: 'list-2', + ), + ); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 2, + ); + + expect(apiClient.calls, ['create_task:list-1', 'move_task:task-server']); + expect(await database.tasksDao.listTasks('account', 'list-1'), isEmpty); + final destination = await database.tasksDao.listTasks('account', 'list-2'); + expect(destination.single.id, 'task-server'); + }); + test( 'Google subtask is moved under its parent after a root insert', () async { @@ -559,6 +819,85 @@ void main() { }, ); + for (final dependentOperation in ['delete_task', 'move_task']) { + final action = dependentOperation == 'delete_task' ? 'delete' : 'move'; + test( + 'existing task edit followed by $action does not self-conflict', + () async { + const baselineUpdatedUtc = '2026-06-04T00:00:00.000Z'; + final baselineRawJson = jsonEncode({ + 'id': 'task-1', + 'title': 'Base title', + 'updated': baselineUpdatedUtc, + }); + apiClient + ..persistTaskPatches = true + ..remoteTask = _taskDto( + 'task-1', + title: 'Base title', + updated: DateTime.parse(baselineUpdatedUtc), + ); + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + title: 'Base title', + updatedUtc: baselineUpdatedUtc, + rawJson: baselineRawJson, + ), + ); + var localEdit = 0; + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++localEdit), + ); + + await repository.patchTask( + 'list-1', + 'task-1', + const TaskPatchInput({'title': 'Local title'}), + ); + if (dependentOperation == 'delete_task') { + await repository.deleteTask('list-1', 'task-1'); + } else { + await repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-1', + taskId: 'task-1', + previousSiblingTaskId: 'task-0', + ), + ); + } + + final queued = await database.pendingOpsDao.pendingOpsForReplay( + 'account', + _later, + ); + expect(queued, hasLength(2)); + final edit = queued.singleWhere( + (operation) => operation.operation == 'patch_task', + ); + final dependent = queued.singleWhere( + (operation) => operation.operation == dependentOperation, + ); + expect(dependent.dependsOnOpId, edit.id); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(applied, 2); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(apiClient.calls, ['patch_task:task-1', '${action}_task:task-1']); + }, + ); + } + test( 'earlier local patch does not hide a genuine conflict on a later field', () async { @@ -1152,11 +1491,13 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { final createParentTaskIds = []; final moveParentTaskIds = []; GoogleTasksApiError? patchTaskListError; + GoogleTasksApiError? createTaskError; GoogleTasksApiError? deleteTaskError; GoogleTasksApiError? clearCompletedError; GoogleTasksApiError? moveTaskError; TaskListDto? remoteTaskList; TaskDto? remoteTask; + Completer? createTaskGate; bool persistTaskPatches = false; int _taskPatchRevision = 0; TasksPageDto remoteTasksPage = const TasksPageDto(items: [], rawJson: {}); @@ -1206,6 +1547,9 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { }) async { calls.add('create_task:$taskListId'); createParentTaskIds.add(parentTaskId); + await createTaskGate?.future; + final error = createTaskError; + if (error != null) throw error; return _taskDto('task-server', title: create.fields['title'].toString()); } @@ -1513,5 +1857,14 @@ TaskDto _taskDto( ); } +Future _waitFor(bool Function() condition) async { + final deadline = DateTime.now().add(const Duration(seconds: 2)); + while (DateTime.now().isBefore(deadline)) { + if (condition()) return; + await Future.delayed(const Duration(milliseconds: 10)); + } + fail('Timed out waiting for condition.'); +} + const _now = '2026-06-04T00:00:00.000Z'; final _later = DateTime.utc(2026, 6, 4, 1); diff --git a/test/features/sync/sync_engine_test.dart b/test/features/sync/sync_engine_test.dart index 0232f4e8..8ad1f2fc 100644 --- a/test/features/sync/sync_engine_test.dart +++ b/test/features/sync/sync_engine_test.dart @@ -71,14 +71,20 @@ void main() { expect(tasks.single.serverMissing, isTrue); }); - test('incremental sync uses updatedMin with two minute overlap', () async { + test('incremental sync checkpoints from the successful run start', () async { await (database.update( database.accounts, )..where((row) => row.id.equals('account'))).write( const AccountsCompanion( - lastSuccessfulSyncAtUtc: Value('2026-06-04T00:10:00.000Z'), + lastSuccessfulSyncAtUtc: Value('2026-06-04T00:15:00.000Z'), ), ); + await _insertSuccessfulRun( + database, + id: 'previous-run', + startedAtUtc: '2026-06-04T00:10:00.000Z', + finishedAtUtc: '2026-06-04T00:15:00.000Z', + ); apiClient.taskListsPages = [ TaskListsPageDto(items: [_taskListDto('list-1')], rawJson: const {}), ]; @@ -90,12 +96,73 @@ void main() { database: database, apiClient: apiClient, accountId: 'account', - nowUtc: () => DateTime.utc(2026, 6, 4), + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 20), ).incrementalSync(); expect(apiClient.lastUpdatedMin, DateTime.utc(2026, 6, 4, 0, 8)); + final account = await (database.select( + database.accounts, + )..where((row) => row.id.equals('account'))).getSingle(); + expect(account.lastSuccessfulSyncAtUtc, '2026-06-04T00:20:00.000Z'); }); + test( + 'Google incremental sync reconciles lists but not omitted tasks', + () async { + await (database.update( + database.accounts, + )..where((row) => row.id.equals('account'))).write( + const AccountsCompanion( + lastSuccessfulSyncAtUtc: Value('2026-06-04T00:15:00.000Z'), + ), + ); + await _insertSuccessfulRun( + database, + id: 'previous-run', + startedAtUtc: '2026-06-04T00:10:00.000Z', + finishedAtUtc: '2026-06-04T00:15:00.000Z', + ); + await database.taskListsDao.upsertTaskList(_localTaskList('list-1')); + await database.taskListsDao.upsertTaskList( + _localTaskList('deleted-list'), + ); + await database.tasksDao.upsertTask(_localTask('unchanged-task')); + await database.tasksDao.upsertTask( + _localTask('cached-task', taskListId: 'deleted-list'), + ); + apiClient.taskListsPages = [ + TaskListsPageDto(items: [_taskListDto('list-1')], rawJson: const {}), + ]; + apiClient.taskPages['list-1'] = [ + const TasksPageDto(items: [], rawJson: {}), + ]; + + await SyncEngine( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ).incrementalSync(); + + final lists = await database.taskListsDao.listTaskLists('account'); + final activeTasks = await database.tasksDao.listTasks( + 'account', + 'list-1', + ); + final deletedListTasks = await database.tasksDao.listTasks( + 'account', + 'deleted-list', + ); + expect( + lists.singleWhere((list) => list.id == 'deleted-list').serverMissing, + isTrue, + ); + expect(activeTasks.single.serverMissing, isFalse); + expect(deletedListTasks.single.serverMissing, isTrue); + expect(apiClient.lastUpdatedMin, DateTime.utc(2026, 6, 4, 0, 8)); + }, + ); + test('full-refresh-only incremental sync does not send updatedMin', () async { await (database.update( database.accounts, @@ -604,6 +671,24 @@ class FakeTaskRemoteClient ) => throw UnimplementedError(); } +Future _insertSuccessfulRun( + AppDatabase database, { + required String id, + required String startedAtUtc, + required String finishedAtUtc, +}) { + return database.syncRunsDao.insertRun( + SyncRunsCompanion.insert( + id: id, + accountId: 'account', + mode: 'incremental', + startedAtUtc: startedAtUtc, + finishedAtUtc: Value(finishedAtUtc), + status: 'success', + ), + ); +} + Future _insertAccount(AppDatabase database) { return database .into(database.accounts) @@ -648,10 +733,11 @@ TasksCompanion _localTask( String id, { String title = 'Local task', bool localDirty = false, + String taskListId = 'list-1', }) { return TasksCompanion.insert( accountId: 'account', - taskListId: 'list-1', + taskListId: taskListId, id: id, title: title, rawJson: '{}', diff --git a/test/features/sync/sync_scheduler_test.dart b/test/features/sync/sync_scheduler_test.dart index 3530c9ee..5ab8aa0f 100644 --- a/test/features/sync/sync_scheduler_test.dart +++ b/test/features/sync/sync_scheduler_test.dart @@ -30,7 +30,7 @@ void main() { test('periodic all-account scheduler syncs all signed-in accounts', () async { final synced = []; final scheduler = AllAccountsSyncScheduler( - listSignedInAccounts: () async => [_account('a'), _account('b')], + listSyncEligibleAccounts: () async => [_account('a'), _account('b')], syncAccount: (accountId) async { synced.add(accountId); }, @@ -39,7 +39,7 @@ void main() { ); scheduler.start(); - addTearDown(scheduler.stop); + addTearDown(scheduler.dispose); await _waitFor(() => synced.length >= 2); expect(synced.take(2), ['a', 'b']); @@ -63,7 +63,7 @@ void main() { test('all-account sync does not enumerate accounts while offline', () async { var listCalls = 0; final scheduler = AllAccountsSyncScheduler( - listSignedInAccounts: () async { + listSyncEligibleAccounts: () async { listCalls += 1; return [_account('a')]; }, @@ -72,6 +72,7 @@ void main() { canSync: () async => false, interval: Duration.zero, ); + addTearDown(scheduler.dispose); await scheduler.runNow(); @@ -83,8 +84,8 @@ void main() { final failure = StateError('a failed'); final failures = []; - await runAllSignedInAccountSync( - listSignedInAccounts: () async => [_account('a'), _account('b')], + await runAllSyncEligibleAccountSync( + listSyncEligibleAccounts: () async => [_account('a'), _account('b')], syncAccount: (accountId) async { synced.add(accountId); if (accountId == 'a') { @@ -109,8 +110,8 @@ void main() { ); final failures = []; - await runAllSignedInAccountSync( - listSignedInAccounts: () async => [_account('a'), _account('b')], + await runAllSyncEligibleAccountSync( + listSyncEligibleAccounts: () async => [_account('a'), _account('b')], syncAccount: (accountId) async { synced.add(accountId); if (accountId == 'a') { @@ -136,7 +137,7 @@ void main() { var starts = 0; final release = Completer(); final scheduler = AllAccountsSyncScheduler( - listSignedInAccounts: () async => [_account('a')], + listSyncEligibleAccounts: () async => [_account('a')], syncAccount: (_) async { starts += 1; active += 1; @@ -149,7 +150,7 @@ void main() { ); scheduler.start(); - addTearDown(scheduler.stop); + addTearDown(scheduler.dispose); await _waitFor(() => starts == 1); await Future.delayed(const Duration(milliseconds: 20)); @@ -157,6 +158,31 @@ void main() { expect(maxActive, 1); release.complete(); }); + + test('all-account scheduler exposes its read-only running state', () async { + final release = Completer(); + final states = []; + final scheduler = AllAccountsSyncScheduler( + listSyncEligibleAccounts: () async => [_account('a')], + syncAccount: (_) => release.future, + onSyncFailure: (_) async {}, + interval: Duration.zero, + ); + final subscription = scheduler.watchRunning().listen(states.add); + addTearDown(() async { + await subscription.cancel(); + await scheduler.dispose(); + }); + + await _waitFor(() => states.isNotEmpty); + final run = scheduler.runNow(); + await _waitFor(() => scheduler.isRunning && states.contains(true)); + release.complete(); + await run; + + expect(scheduler.isRunning, isFalse); + expect(states, [false, true, false]); + }); } class _FailingSyncEngine implements SyncEngine { diff --git a/test/features/task_lists/data/task_lists_repository_test.dart b/test/features/task_lists/data/task_lists_repository_test.dart index 465bea78..97548af1 100644 --- a/test/features/task_lists/data/task_lists_repository_test.dart +++ b/test/features/task_lists/data/task_lists_repository_test.dart @@ -6,6 +6,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/dav/mutation/dav_task_list_mutation_service.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; +import 'package:busymax/src/features/tasks/domain/task_remote_models.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; void main() { late AppDatabase database; @@ -48,6 +50,56 @@ void main() { expect(mutationQueuedCalls, 1); }); + test('Microsoft createTaskList keeps the cloud pending path', () async { + await (database.update( + database.accounts, + )..where((row) => row.id.equals('account'))).write( + const AccountsCompanion( + provider: Value('microsoft'), + authority: Value('https://login.microsoftonline.com/common'), + providerAccountId: Value('microsoft-account'), + ), + ); + + await repository.createTaskList('Microsoft list'); + + final list = await database.select(database.taskLists).getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + expect(list.id, startsWith('local-tasklist-')); + expect(operation.operation, 'create_task_list'); + expect(jsonDecode(operation.requestJson), {'title': 'Microsoft list'}); + }); + + test('createTaskList requires a non-empty title', () async { + await expectLater(repository.createTaskList(' '), throwsArgumentError); + expect(await database.select(database.taskLists).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + for (final provider in [BusyProvider.appleICloud, BusyProvider.webCal]) { + test('${provider.displayName} task-list creation is rejected', () async { + await (database.update( + database.accounts, + )..where((row) => row.id.equals('account'))).write( + AccountsCompanion( + provider: Value(provider.storageValue), + credentialKind: Value( + provider == BusyProvider.webCal + ? 'webcal_subscription' + : 'apple_app_specific_password', + ), + ), + ); + + await expectLater( + repository.createTaskList('Unsupported'), + throwsStateError, + ); + expect(await database.select(database.taskLists).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + } + test('renameTaskList updates local row and queues patch op', () async { var mutationQueuedCalls = 0; repository = _repository( @@ -119,6 +171,54 @@ void main() { expect(watchedLists.map((list) => list.id), ['visible']); }); + test( + 'setRemindersEnabled updates only the local notification policy', + () async { + var scheduleChangedCalls = 0; + repository = TaskListsRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + onNotificationScheduleChanged: () async { + scheduleChangedCalls += 1; + }, + ); + await database.taskListsDao.upsertTaskList(_taskList('list-1')); + + await repository.setRemindersEnabled('list-1', false); + + final list = await database.select(database.taskLists).getSingle(); + expect(list.remindersEnabled, isFalse); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(scheduleChangedCalls, 1); + }, + ); + + test( + 'provider refresh preserves the local task-list reminder policy', + () async { + await database.taskListsDao.upsertTaskList( + _taskList('list-1', remindersEnabled: const Value(false)), + ); + + await database.taskListsDao.upsertTaskList( + taskListFromDto( + 'account', + const TaskListDto( + id: 'list-1', + title: 'Renamed remotely', + rawJson: {'id': 'list-1', 'title': 'Renamed remotely'}, + ), + _now, + ), + ); + + final list = await database.select(database.taskLists).getSingle(); + expect(list.title, 'Renamed remotely'); + expect(list.remindersEnabled, isFalse); + }, + ); + test( 'Nextcloud list mutations use CalDAV instead of REST pending ops', () async { @@ -205,6 +305,7 @@ Future _insertAccount(AppDatabase database) { authority: 'https://accounts.google.com', providerAccountId: 'google-account', credentialKind: 'oauth', + authState: const Value('signed_in'), createdAtUtc: _now, updatedAtUtc: _now, ), @@ -217,6 +318,7 @@ TaskListsCompanion _taskList( String id, { Value pendingDelete = const Value.absent(), Value serverMissing = const Value.absent(), + Value remindersEnabled = const Value.absent(), }) { return TaskListsCompanion.insert( accountId: 'account', @@ -224,6 +326,7 @@ TaskListsCompanion _taskList( title: 'Inbox', pendingDelete: pendingDelete, serverMissing: serverMissing, + remindersEnabled: remindersEnabled, rawJson: '{}', createdLocalAtUtc: _now, updatedLocalAtUtc: _now, diff --git a/test/features/tasks/data/tasks_repository_test.dart b/test/features/tasks/data/tasks_repository_test.dart index 57bdda1f..bb97e930 100644 --- a/test/features/tasks/data/tasks_repository_test.dart +++ b/test/features/tasks/data/tasks_repository_test.dart @@ -62,6 +62,104 @@ void main() { expect(mutationQueuedCalls, 1); }); + test('new task mutations form a chain rooted at its creation', () async { + var tick = 0; + repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, tick++), + ); + await repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Draft'), + ); + final task = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + + await repository.patchTask( + 'list-1', + task.id, + const TaskPatchInput({'title': 'Patched'}), + ); + await repository.updateTaskFull( + 'list-1', + task.id, + TaskPutInput({'title': 'Updated'}), + ); + await repository.moveTask( + TaskMoveInput(sourceTaskListId: 'list-1', taskId: task.id), + ); + + final operations = await database.select(database.pendingOps).get(); + final create = operations.singleWhere( + (operation) => operation.operation == 'create_task', + ); + final patch = operations.singleWhere( + (operation) => operation.operation == 'patch_task', + ); + final update = operations.singleWhere( + (operation) => operation.operation == 'update_task', + ); + final move = operations.singleWhere( + (operation) => operation.operation == 'move_task', + ); + expect(patch.dependsOnOpId, create.id); + expect(update.dependsOnOpId, patch.id); + expect(move.dependsOnOpId, update.id); + }); + + test('deleting an unsent task cancels its pending operation chain', () async { + await repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Draft'), + ); + final task = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + await repository.patchTask( + 'list-1', + task.id, + const TaskPatchInput({'title': 'Changed'}), + ); + await repository.moveTask( + TaskMoveInput(sourceTaskListId: 'list-1', taskId: task.id), + ); + + await repository.deleteTask('list-1', task.id); + + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(await database.tasksDao.listTasks('account', 'list-1'), isEmpty); + }); + + test('deleting an attempted create waits for its server identity', () async { + await repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Draft'), + ); + final task = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + final create = await database.select(database.pendingOps).getSingle(); + await (database.update( + database.pendingOps, + )..where((row) => row.id.equals(create.id))).write( + const PendingOpsCompanion(state: Value('retry'), attemptCount: Value(1)), + ); + + await repository.deleteTask('list-1', task.id); + + final operations = await database.select(database.pendingOps).get(); + final delete = operations.singleWhere( + (operation) => operation.operation == 'delete_task', + ); + expect(operations, hasLength(2)); + expect(delete.dependsOnOpId, create.id); + }); + test('Google subtask queues a dependent hierarchy move', () async { await database.tasksDao.upsertTask(_task(id: 'parent', position: '1')); diff --git a/test/features/tasks/presentation/desktop_date_time_fields_test.dart b/test/features/tasks/presentation/desktop_date_time_fields_test.dart index 43020861..1807fe4c 100644 --- a/test/features/tasks/presentation/desktop_date_time_fields_test.dart +++ b/test/features/tasks/presentation/desktop_date_time_fields_test.dart @@ -539,6 +539,7 @@ void main() { late BuildContext hostContext; await tester.pumpWidget( localizedTestApp( + locale: const Locale('ru'), child: Builder( builder: (context) { hostContext = context; @@ -557,6 +558,7 @@ void main() { expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); expect(find.byType(ModalBarrier), findsAtLeastNWidgets(1)); + expect(find.text('Июль'), findsOneWidget); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index 680b2dde..2602e775 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -44,6 +44,7 @@ String _testAuthority(BusyProvider provider) => switch (provider) { BusyProvider.microsoft => 'https://login.microsoftonline.com/common', BusyProvider.appleICloud => 'https://caldav.icloud.com', BusyProvider.nextcloud => 'https://cloud.example.test', + BusyProvider.webCal => 'https://calendar.example.test', }; void main() { diff --git a/test/features/tray/tray_presentation_test.dart b/test/features/tray/tray_presentation_test.dart new file mode 100644 index 00000000..2dd163e0 --- /dev/null +++ b/test/features/tray/tray_presentation_test.dart @@ -0,0 +1,482 @@ +import 'package:busymax/src/app/app_settings.dart'; +import 'package:busymax/src/core/secrets/secret_store.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; +import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; +import 'package:busymax/src/features/tray/data/tray_presentation_service.dart'; +import 'package:busymax/src/features/tray/domain/tray_presentation.dart'; +import 'package:busymax/src/features/tray/domain/tray_presentation_formatter.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/schedule/schedule_filters.dart'; +import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final now = DateTime(2026, 8, 29, 10); + + test('projects all-day, ongoing, later, and missing-end events', () { + final presentation = _project( + now: now, + scheduleItems: [ + _event('all-day', start: DateTime(2026, 8, 29), allDay: true), + _event( + 'ended', + start: DateTime(2026, 8, 29, 8), + end: DateTime(2026, 8, 29, 9), + ), + _event( + 'ongoing', + start: DateTime(2026, 8, 29, 9, 30), + end: DateTime(2026, 8, 29, 10, 30), + ), + _event('missing-end-past', start: DateTime(2026, 8, 29, 9, 45)), + _event('later', start: DateTime(2026, 8, 29, 11)), + _event('missing-end-future', start: DateTime(2026, 8, 29, 12)), + ], + ); + + expect(presentation.events.map((event) => event.eventId), [ + 'all-day', + 'ongoing', + 'later', + ]); + }); + + test('preserves schedule ordering and limits event rows to three', () { + final presentation = _project( + now: now, + scheduleItems: [ + _event('z-first', start: DateTime(2026, 8, 29, 10, 5)), + _event('a-second', start: DateTime(2026, 8, 29, 10, 10)), + _event('third', start: DateTime(2026, 8, 29, 10, 15)), + _event('fourth', start: DateTime(2026, 8, 29, 10, 20)), + ], + ); + + expect(presentation.events.map((event) => event.eventId), [ + 'z-first', + 'a-second', + 'third', + ]); + }); + + test('hidden and deselected calendars are excluded defensively', () { + final presentation = _project( + now: now, + calendarSources: [ + _source(id: 'visible'), + _source(id: 'hidden', hidden: true), + _source(id: 'deselected', selected: false), + ], + scheduleItems: [ + _event('visible-event', sourceId: 'visible', start: now), + _event('hidden-event', sourceId: 'hidden', start: now), + _event('deselected-event', sourceId: 'deselected', start: now), + ], + ); + + expect(presentation.events.single.eventId, 'visible-event'); + }); + + test('task summary respects visibility, completion, and no-date state', () { + const visibleKey = ScheduleTaskListKey( + accountId: 'account', + taskListId: 'visible-list', + ); + final presentation = _project( + now: now, + taskLists: const [ + TaskListEntity( + accountId: 'account', + id: 'visible-list', + title: 'Visible', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + TaskListEntity( + accountId: 'account', + id: 'hidden-list', + title: 'Hidden', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + ], + settings: AppSettings.defaults().copyWith( + taskListScheduleVisibility: const {'account::hidden-list': false}, + ), + writableTaskListKeys: {visibleKey}, + scheduleItems: [ + _task('due', sourceId: 'visible-list', start: now), + _task('done', sourceId: 'visible-list', start: now, completed: true), + _task('no-date', sourceId: 'visible-list'), + _task('hidden', sourceId: 'hidden-list', start: now), + ], + ); + + expect(presentation.incompleteTasksDueToday, 1); + expect(presentation.canCreateTask, isTrue); + }); + + test('read-only WebCal events display without enabling creation', () { + final presentation = _project( + now: now, + accounts: [_account(provider: BusyProvider.webCal)], + calendarSources: [ + _source( + provider: BusyProvider.webCal, + readOnly: true, + accountId: 'account', + ), + ], + scheduleItems: [ + _event('webcal-event', provider: BusyProvider.webCal, start: now), + ], + ); + + expect(presentation.events.single.eventId, 'webcal-event'); + expect(presentation.canCreateEvent, isFalse); + }); + + test('visible capability models control quick creation', () { + const taskKey = ScheduleTaskListKey( + accountId: 'account', + taskListId: 'tasks', + ); + final presentation = _project( + now: now, + calendarSources: [_source()], + taskLists: const [ + TaskListEntity( + accountId: 'account', + id: 'tasks', + title: 'Tasks', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + ], + writableTaskListKeys: {taskKey}, + ); + + expect(presentation.canCreateEvent, isTrue); + expect(presentation.canCreateTask, isTrue); + }); + + test('uses the least fresh successful account synchronization', () { + final presentation = _project( + now: now, + accounts: [ + _account(lastSync: DateTime.utc(2026, 8, 29, 9, 50)), + _account(id: 'second', lastSync: DateTime.utc(2026, 8, 29, 8)), + ], + ); + expect( + presentation.lastSuccessfulSynchronizationUtc, + DateTime.utc(2026, 8, 29, 8), + ); + + final neverSynced = _project( + now: now, + accounts: [ + _account(lastSync: DateTime.utc(2026, 8, 29, 9)), + _account(id: 'second'), + ], + ); + expect(neverSynced.lastSuccessfulSynchronizationUtc, isNull); + }); + + test('formats all-day, ongoing, future, fallback, and sanitized labels', () { + final presentation = _project( + now: now, + scheduleItems: [ + _event( + 'all-day', + title: ' Release\n\u0007 freeze ', + start: DateTime(2026, 8, 29), + allDay: true, + ), + _event( + 'ongoing', + title: '', + start: DateTime(2026, 8, 29, 9), + end: DateTime(2026, 8, 29, 11), + ), + _event( + 'future', + title: List.filled(120, 'x').join(), + start: DateTime(2026, 8, 29, 10, 30), + ), + ], + ); + final menu = _formatter.format(presentation); + + expect(menu.eventRows[0].label, 'All day · Release freeze'); + expect(menu.eventRows[1].label, 'Now · Untitled event'); + expect(menu.eventRows[2].label, startsWith('10:30 · ')); + expect(menu.eventRows[2].label, endsWith('…')); + expect(menu.eventRows[2].label.runes.length, 100); + }); + + test('private detail removes every event title', () { + final presentation = _project( + now: now, + settings: AppSettings.defaults().copyWith( + notificationDetailLevel: NotificationDetailLevel.private, + ), + scheduleItems: [ + _event( + 'all-day', + title: 'All-day secret', + start: DateTime(2026, 8, 29), + allDay: true, + ), + _event( + 'ongoing', + title: 'Ongoing secret', + start: DateTime(2026, 8, 29, 9), + end: DateTime(2026, 8, 29, 11), + ), + ], + ); + final labels = _formatter + .format(presentation) + .eventRows + .map((row) => row.label) + .toList(); + + expect(labels, ['All day · Calendar event', 'Now · Calendar event']); + expect(labels.join(' '), isNot(contains('secret'))); + }); + + test('normal Unicode event titles are preserved', () { + final menu = _formatter.format( + _project( + now: now, + scheduleItems: [_event('unicode', title: 'Réunion 東京', start: now)], + ), + ); + + expect(menu.eventRows.single.label, '10:00 · Réunion 東京'); + }); + + test('formats singular, plural, and empty Today states', () { + final one = _formatter.format( + _project( + now: now, + scheduleItems: [_task('one', start: now)], + ), + ); + final many = _formatter.format( + _project( + now: now, + scheduleItems: [ + _task('one', start: now), + _task('two', start: now), + _task('three', start: now), + ], + ), + ); + final empty = _formatter.format(_project(now: now)); + + expect(one.taskSummaryLabel, '1 task due today'); + expect(many.taskSummaryLabel, '3 tasks due today'); + expect(empty.showEmptyState, isTrue); + expect(empty.emptyStateLabel, 'Nothing else today'); + }); + + test('formats synchronization status precedence and relative time', () { + BusyMaxTrayMenuPresentation format({ + NetworkAvailability connectivity = NetworkAvailability.online, + bool syncing = false, + List? accounts, + }) => _formatter.format( + _project( + now: now, + connectivity: connectivity, + synchronizationRunning: syncing, + accounts: + accounts ?? + [ + _account( + lastSync: now.toUtc().subtract(const Duration(minutes: 5)), + ), + ], + ), + ); + + expect( + format( + connectivity: NetworkAvailability.offline, + syncing: true, + ).synchronizationStatusLabel, + 'Offline — Changes will sync when connected.', + ); + expect(format(syncing: true).synchronizationStatusLabel, 'Syncing…'); + expect(format(accounts: []).synchronizationStatusLabel, 'Not connected'); + expect( + format(accounts: [_account()]).synchronizationStatusLabel, + 'Not yet synced', + ); + expect(format().synchronizationStatusLabel, 'Last synced 5 minutes ago'); + expect( + format(connectivity: NetworkAvailability.offline).canSynchronize, + isFalse, + ); + expect(format(syncing: true).canSynchronize, isFalse); + expect(format(accounts: []).canSynchronize, isFalse); + expect(format().canSynchronize, isTrue); + }); +} + +BusyMaxTrayPresentation _project({ + required DateTime now, + List? accounts, + List? calendarSources, + List? taskLists, + List scheduleItems = const [], + Set writableTaskListKeys = const {}, + AppSettings? settings, + NetworkAvailability connectivity = NetworkAvailability.online, + bool synchronizationRunning = false, +}) { + return projectBusyMaxTrayPresentation( + accounts: accounts ?? [_account()], + calendarSources: calendarSources ?? [_source()], + taskLists: + taskLists ?? + const [ + TaskListEntity( + accountId: 'account', + id: 'visible-list', + title: 'Visible', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + ], + scheduleItems: scheduleItems, + writableTaskListKeys: writableTaskListKeys, + settings: settings ?? AppSettings.defaults(), + connectivity: connectivity, + synchronizationRunning: synchronizationRunning, + now: now, + ); +} + +AccountEntity _account({ + String id = 'account', + BusyProvider provider = BusyProvider.google, + DateTime? lastSync, +}) { + return AccountEntity( + id: id, + provider: provider, + authority: 'https://example.test', + providerAccountId: id, + credentialKind: provider == BusyProvider.webCal + ? CredentialKind.webCalSubscription + : CredentialKind.oauth, + authState: accountAuthStateSignedIn, + lastSuccessfulSyncAtUtc: lastSync, + ); +} + +CalendarSourceEntity _source({ + String id = 'calendar', + String accountId = 'account', + BusyProvider provider = BusyProvider.google, + bool selected = true, + bool hidden = false, + bool readOnly = false, +}) { + return CalendarSourceEntity( + id: id, + accountId: accountId, + provider: provider, + providerCalendarId: id, + summary: id, + selected: selected, + hidden: hidden, + readOnly: readOnly, + isDeleted: false, + ); +} + +CalendarScheduleItem _event( + String id, { + String accountId = 'account', + String sourceId = 'calendar', + BusyProvider provider = BusyProvider.google, + String? title, + required DateTime start, + DateTime? end, + bool allDay = false, +}) { + return CalendarScheduleItem( + id: id, + accountId: accountId, + provider: provider, + sourceId: sourceId, + providerCalendarId: sourceId, + title: title ?? id, + allDay: allDay, + start: start, + end: end, + ); +} + +TaskScheduleItem _task( + String id, { + String sourceId = 'visible-list', + DateTime? start, + bool completed = false, +}) { + return TaskScheduleItem( + id: id, + accountId: 'account', + provider: BusyProvider.google, + sourceId: sourceId, + title: id, + completed: completed, + allDay: true, + start: start, + ); +} + +final _formatter = BusyMaxTrayPresentationFormatter( + BusyMaxTrayPresentationStrings( + showBusyMax: 'Show BusyMax', + newEvent: 'New event…', + newTask: 'New task…', + today: 'Today', + allDay: 'All day', + now: 'Now', + calendarEvent: 'Calendar event', + untitledEvent: 'Untitled event', + nothingElseToday: 'Nothing else today', + openTodayAgenda: 'Open today’s agenda', + syncNow: 'Sync now', + syncing: 'Syncing…', + notConnected: 'Not connected', + notYetSynced: 'Not yet synced', + settings: 'Settings', + quitBusyMax: 'Quit BusyMax', + offline: 'Offline', + offlineDescription: 'Changes will sync when connected.', + formatTime: (value) => + '${value.hour.toString().padLeft(2, '0')}:' + '${value.minute.toString().padLeft(2, '0')}', + tasksDueToday: (count) => + count == 1 ? '1 task due today' : '$count tasks due today', + lastSyncedJustNow: 'Last synced just now', + lastSyncedMinutesAgo: (count) => + 'Last synced $count minute${count == 1 ? '' : 's'} ago', + lastSyncedHoursAgo: (count) => + 'Last synced $count hour${count == 1 ? '' : 's'} ago', + lastSyncedDaysAgo: (count) => + 'Last synced $count day${count == 1 ? '' : 's'} ago', + ), +); diff --git a/test/google_calendar/google_calendar_api_client_test.dart b/test/google_calendar/google_calendar_api_client_test.dart index eef4a8cb..605ab354 100644 --- a/test/google_calendar/google_calendar_api_client_test.dart +++ b/test/google_calendar/google_calendar_api_client_test.dart @@ -13,6 +13,114 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; void main() { + test('calendar RGB color updates the Google CalendarList entry', () async { + late http.Request captured; + final client = _client((request) { + captured = request; + return _json({ + 'id': 'calendar@example.com', + 'summary': 'Work', + 'backgroundColor': '#3584e4', + 'foregroundColor': '#ffffff', + 'accessRole': 'owner', + }); + }); + + final source = await client.updateCalendar( + 'calendar@example.com', + const CalendarMutation( + backgroundColor: '#3584e4', + foregroundColor: '#ffffff', + ), + ); + + expect(captured.method, 'PATCH'); + expect( + captured.url.path, + '/calendar/v3/users/me/calendarList/calendar%40example.com', + ); + expect(captured.url.queryParameters, {'colorRgbFormat': 'true'}); + expect(jsonDecode(captured.body), { + 'backgroundColor': '#3584e4', + 'foregroundColor': '#ffffff', + }); + expect(source.backgroundColor, '#3584e4'); + }); + + test('calendar rename updates the calendar resource', () async { + final requests = []; + final client = _client((request) { + requests.add(request); + return _json({ + 'id': 'calendar@example.com', + 'summary': 'Renamed', + 'dataOwner': 'owner@example.com', + 'accessRole': 'owner', + }); + }); + + await client.updateCalendar( + 'calendar@example.com', + const CalendarMutation(summary: 'Renamed'), + ); + + expect(requests, hasLength(2)); + expect(requests[0].method, 'PATCH'); + expect( + requests[0].url.path, + '/calendar/v3/calendars/calendar%40example.com', + ); + expect(requests[0].url.queryParameters, isEmpty); + expect(jsonDecode(requests[0].body), {'summary': 'Renamed'}); + expect(requests[1].method, 'GET'); + expect( + requests[1].url.path, + '/calendar/v3/users/me/calendarList/calendar%40example.com', + ); + }); + + test('personal calendar name updates summaryOverride', () async { + late http.Request captured; + final client = _client((request) { + captured = request; + return _json({ + 'id': 'shared@example.com', + 'summary': 'Team', + 'summaryOverride': 'My team', + 'dataOwner': 'owner@example.com', + }); + }); + + final source = await client.updateCalendarListEntry( + 'shared@example.com', + const CalendarMutation(summary: 'My team'), + ); + + expect(captured.method, 'PATCH'); + expect( + captured.url.path, + '/calendar/v3/users/me/calendarList/shared%40example.com', + ); + expect(jsonDecode(captured.body), {'summaryOverride': 'My team'}); + expect(source.summary, 'My team'); + }); + + test('shared calendar removal deletes only the CalendarList entry', () async { + late http.Request captured; + final client = _client((request) { + captured = request; + return http.Response('', 204); + }); + + await client.deleteCalendarListEntry('shared@example.com'); + + expect(captured.method, 'DELETE'); + expect( + captured.url.path, + '/calendar/v3/users/me/calendarList/shared%40example.com', + ); + }); + test('event mutations always send an explicit guest update policy', () async { final requests = []; final client = _client((request) { @@ -42,6 +150,32 @@ void main() { expect(requests[2].url.queryParameters['sendUpdates'], 'none'); expect(requests[0].url.queryParameters['conferenceDataVersion'], '1'); expect(requests[1].url.queryParameters['conferenceDataVersion'], '1'); + expect(jsonDecode(requests[1].body), {'summary': 'Updated'}); + }); + + test('native event move sends destination and guest update policy', () async { + late http.Request captured; + final client = _client((request) { + captured = request; + return _json(_googleEventJson()); + }); + + await client.moveEvent( + sourceCalendarId: 'source@example.com', + eventId: 'event-1', + destinationCalendarId: 'destination@example.com', + guestUpdatePolicy: CalendarGuestUpdatePolicy.doNotSend, + ); + + expect(captured.method, 'POST'); + expect( + captured.url.path, + '/calendar/v3/calendars/source%40example.com/events/event-1/move', + ); + expect(captured.url.queryParameters, { + 'destination': 'destination@example.com', + 'sendUpdates': 'none', + }); }); test('Google RSVP updates only the signed-in attendee response', () async { diff --git a/test/google_calendar/google_calendar_mapper_test.dart b/test/google_calendar/google_calendar_mapper_test.dart index f7d8ba6b..29910407 100644 --- a/test/google_calendar/google_calendar_mapper_test.dart +++ b/test/google_calendar/google_calendar_mapper_test.dart @@ -1,8 +1,35 @@ +import 'package:busymax/src/calendar_providers/calendar_create_identity.dart'; import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; import 'package:busymax/src/google_calendar/google_calendar_mapper.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('Google create identity is stable and uses the valid ID alphabet', () { + final first = googleCalendarCreateEventId( + '00112233-4455-6677-8899-aabbccddeeff', + ); + final second = googleCalendarCreateEventId( + '00112233-4455-6677-8899-aabbccddeeff', + ); + + expect(first, second); + expect(first, matches(RegExp(r'^[0-9a-v]{5,1024}$'))); + }); + + test('Google calendar source maps data owner and personal name', () { + final source = googleCalendarSourceFromJson({ + 'id': 'shared@example.com', + 'summary': 'Team calendar', + 'summaryOverride': 'My team', + 'dataOwner': 'owner@example.com', + 'accessRole': 'reader', + }); + + expect(source.summary, 'My team'); + expect(source.dataOwner, 'owner@example.com'); + expect(source.readOnly, isTrue); + }); + test('Google guest visibility maps from the shared hide-attendees field', () { expect( googleEventMutationToJson( @@ -33,4 +60,13 @@ void main() { conference, ); }); + + test('Google event create serializes the client-assigned event ID', () { + expect( + googleEventMutationToJson( + const CalendarEventMutation(providerEventId: '0123456789abcdef'), + )['id'], + '0123456789abcdef', + ); + }); } diff --git a/test/google_tasks/discovery_surface_test.dart b/test/google_tasks/discovery_surface_test.dart index c4d76cfb..22150a42 100644 --- a/test/google_tasks/discovery_surface_test.dart +++ b/test/google_tasks/discovery_surface_test.dart @@ -9,7 +9,7 @@ void main() { final discovery = jsonDecode( File( - 'tools/google_tasks_discovery/cache/tasks_v1_discovery.json', + 'tool/google_tasks_discovery/cache/tasks_v1_discovery.json', ).readAsStringSync(), ) as Map; diff --git a/test/google_tasks/oauth/loopback_flow_test.dart b/test/google_tasks/oauth/loopback_flow_test.dart index 98591850..e0f16c67 100644 --- a/test/google_tasks/oauth/loopback_flow_test.dart +++ b/test/google_tasks/oauth/loopback_flow_test.dart @@ -130,6 +130,68 @@ void main() { ), ); }); + + for (final platform in ['linux', 'windows']) { + test( + '$platform browser launch false returns visible OAuth error', + () async { + final flow = OAuthLoopbackFlow( + authorizationLauncher: (_) async => false, + ); + await expectLater( + flow.start( + authorizationEndpoint: _authorizationEndpoint, + clientId: 'client-id', + scope: googleTasksOAuthScope, + browserLaunchFailureMessage: 'Could not open the system browser.', + ), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + 'OAuthBrowserLaunchFailed', + ) + .having( + (error) => error.message, + 'message', + 'Could not open the system browser.', + ), + ), + ); + }, + ); + + test('$platform browser launch exception is sanitized', () async { + final flow = OAuthLoopbackFlow( + authorizationLauncher: (_) => throw StateError('private shell error'), + ); + await expectLater( + flow.start( + authorizationEndpoint: _authorizationEndpoint, + clientId: 'client-id', + scope: googleTasksOAuthScope, + browserLaunchFailureMessage: 'Could not open the system browser.', + ), + throwsA( + isA() + .having((error) => error.code, 'code', 'OAuthBrowserLaunchFailed') + .having( + (error) => error.message, + 'message', + isNot(contains('private shell error')), + ), + ), + ); + }); + } + + test('Windows-reachable OAuth implementation contains no xdg-open', () { + final source = File( + 'lib/src/google_tasks/oauth/oauth_loopback_flow.dart', + ).readAsStringSync(); + expect(source, isNot(contains('xdg-open'))); + }); } Future<_StartedFlow> _startFlow({ diff --git a/test/google_tasks/oauth/oauth_token_store_test.dart b/test/google_tasks/oauth/oauth_token_store_test.dart index d903b6bb..b5d3a684 100644 --- a/test/google_tasks/oauth/oauth_token_store_test.dart +++ b/test/google_tasks/oauth/oauth_token_store_test.dart @@ -5,6 +5,8 @@ import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/core/secrets/secret_store.dart'; import 'package:busymax/src/core/secrets/portal_encrypted_secret_store.dart'; import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/platform/linux/linux_secret_storage_presentation.dart'; +import 'package:busymax/src/platform/windows/windows_secret_storage_presentation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -52,6 +54,69 @@ void main() { }, ); + test( + 'platform secure-storage guidance never crosses OS boundaries', + () async { + Future failure( + SecretStoragePresentation presentation, + ) async { + final store = SecureSecretStore( + _ThrowingSecureStorage( + PlatformException( + code: 'Unavailable', + message: 'password=must-not-appear', + ), + ), + presentation: presentation, + ); + try { + await store.readActiveAccountId(); + fail('Expected secure storage failure.'); + } on SecretStoreException catch (error) { + return error; + } + } + + final windows = await failure(windowsSecretStoragePresentation); + expect(windows.message, contains('Windows credential storage')); + expect(windows.message.toLowerCase(), isNot(contains('keyring'))); + expect(windows.message.toLowerCase(), isNot(contains('portal'))); + expect(windows.message.toLowerCase(), isNot(contains('snap'))); + + final linux = await failure(linuxSecretStoragePresentation); + expect(linux.message.toLowerCase(), contains('system keyring')); + }, + ); + + test('secure-storage diagnostics are platform-owned and redacted', () { + final error = PlatformException( + code: 'BackendFailure', + message: 'password=credential-value', + details: {'token': 'another-secret'}, + ); + final windows = sanitizedFlutterSecureStorageError( + error, + backendDomain: windowsSecretStoragePresentation.backendDomain, + ); + final linux = sanitizedFlutterSecureStorageError( + error, + backendDomain: linuxSecretStoragePresentation.backendDomain, + ); + expect(windows, contains('domain=busymax.secure_storage.windows')); + expect(windows, isNot(contains('credential-value'))); + expect(windows, isNot(contains('another-secret'))); + expect(windows, isNot(contains('flutter_secure_storage_linux'))); + expect(linux, contains('domain=busymax.secure_storage.linux')); + expect(linux, isNot(contains('credential-value'))); + + final unsafeCode = sanitizedFlutterSecureStorageError( + PlatformException(code: 'token=credential-value'), + backendDomain: windowsSecretStoragePresentation.backendDomain, + ); + expect(unsafeCode, contains('code=backend-error')); + expect(unsafeCode, isNot(contains('credential-value'))); + }); + test( 'credential records are versioned, typed, and redacted in diagnostics', () { @@ -66,6 +131,10 @@ void main() { loginName: 'alex', appPassword: 'nextcloud-app-secret', ), + WebCalSecretRecord( + normalizedSubscriptionUri: + 'https://calendar.example.test/feed?token=webcal-secret', + ), ]; for (final record in records) { @@ -85,6 +154,26 @@ void main() { }, ); + test('credential decoding rejects mismatched provider and kind', () { + final webCal = WebCalSecretRecord( + normalizedSubscriptionUri: + 'https://calendar.example.test/feed?token=secret', + ).toJson(); + webCal['provider'] = BusyProvider.google.storageValue; + + expect( + () => SecretRecord.fromJson(webCal), + throwsA(isA()), + ); + expect( + () => OAuthSecretRecord( + provider: BusyProvider.appleICloud, + tokenSet: _tokenSet(), + ), + throwsA(isA()), + ); + }); + test('typed reads reject a credential from another provider', () async { final store = InMemorySecretStore(); await store.saveOAuthTokenSet( @@ -191,6 +280,7 @@ void main() { expect((await storageFile.stat()).mode & 0x1ff, 0x180); expect((await storageFile.parent.stat()).mode & 0x1ff, 0x1c0); }, + skip: !Platform.isLinux, ); test('portal encrypted token store maps portal failures', () async { diff --git a/test/ical/ical_import_service_test.dart b/test/ical/ical_import_service_test.dart new file mode 100644 index 00000000..276a7dd0 --- /dev/null +++ b/test/ical/ical_import_service_test.dart @@ -0,0 +1,321 @@ +import 'dart:convert'; + +import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.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'; + +void main() { + late AppDatabase database; + late CalendarRepository calendarRepository; + late IcalImportService importService; + late int notificationRebuilds; + + setUp(() async { + database = AppDatabase(NativeDatabase.memory()); + notificationRebuilds = 0; + calendarRepository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 8, 29), + onNotificationScheduleChanged: () async => notificationRebuilds += 1, + ); + importService = IcalImportService( + database: database, + calendarRepository: calendarRepository, + ); + await _seedAccountAndSource(database); + }); + + tearDown(() async => database.close()); + + test( + 'preview performs no mutation and reports omitted scheduling fields', + () { + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +METHOD:REQUEST +BEGIN:VEVENT +UID:invitation-copy +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Meeting copy +ORGANIZER:mailto:organizer@example.test +ATTENDEE:mailto:guest@example.test +URL:https://meeting.example.test/join +ATTACH:https://files.example.test/agenda +END:VEVENT +'''), + ), + ); + + expect(preview.eventCount, 1); + expect( + preview.fieldsThatWillBeOmitted, + containsAll([ + 'scheduling method', + 'attendees', + 'organizer', + 'URL', + 'attachments', + ]), + ); + expect(notificationRebuilds, 0); + }, + ); + + test( + 'queues a private offline copy once and records durable UID receipt', + () async { + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +METHOD:REQUEST +BEGIN:VEVENT +UID:recurring-import +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Imported series +DESCRIPTION:Description +LOCATION:Room 2 +RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=SU +CATEGORIES:One,Two +CLASS:PRIVATE +TRANSP:TRANSPARENT +ORGANIZER:mailto:organizer@example.test +ATTENDEE;ROLE=REQ-PARTICIPANT:mailto:guest@example.test +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER:-PT10M +DESCRIPTION:Reminder +END:VALARM +END:VEVENT +'''), + ), + ); + final destination = (await importService.writableDestinations()).single; + + final report = await importService.importPreview( + preview: preview, + destination: destination, + ); + + expect(report.queued, 1); + expect(report.duplicatesSkipped, 0); + expect(report.unsupportedRecurrenceSets, isEmpty); + expect(notificationRebuilds, 1); + final event = await database.select(database.calendarEvents).getSingle(); + expect(event.title, 'Imported series'); + expect(event.attendeesJson, isNull); + expect(event.organizerJson, '{"self":true}'); + expect(event.organizerJson, isNot(contains('organizer@example.test'))); + expect(event.conferenceJson, isNull); + expect(event.syncStatus, 'pending'); + expect(event.recurrenceJson, isNotNull); + final operation = await database.select(database.pendingOps).getSingle(); + final request = jsonDecode(operation.requestJson) as Map; + expect(request[calendarEventGuestUpdatePolicyKey], 'doNotSend'); + expect(request['attendeesJson'], isNull); + expect(request, isNot(contains('organizer'))); + expect(request, isNot(contains('conferenceJson'))); + expect( + (await database.select(database.icalImportReceipts).getSingle()) + .icalUid, + 'recurring-import', + ); + + final repeated = await importService.importPreview( + preview: preview, + destination: destination, + ); + expect(repeated.queued, 0); + expect(repeated.duplicatesSkipped, 1); + expect( + await database.select(database.calendarEvents).get(), + hasLength(1), + ); + expect(await database.select(database.pendingOps).get(), hasLength(1)); + expect(notificationRebuilds, 1); + }, + ); + + test( + 'skips a whole recurrence set rather than dropping an exception', + () async { + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:with-exception +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Master +RRULE:FREQ=DAILY;COUNT=2 +END:VEVENT +BEGIN:VEVENT +UID:with-exception +RECURRENCE-ID:20260831T160000Z +DTSTART:20260831T180000Z +DTEND:20260831T190000Z +SUMMARY:Moved +END:VEVENT +'''), + ), + ); + + final report = await importService.importPreview( + preview: preview, + 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(await database.select(database.calendarEvents).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + + test( + 'skips an embedded custom timezone the destination cannot carry', + () async { + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +BEGIN:VTIMEZONE +TZID:Custom/Office +BEGIN:STANDARD +DTSTART:19700101T000000 +TZOFFSETFROM:-0800 +TZOFFSETTO:-0800 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +UID:custom-timezone +DTSTART;TZID=Custom/Office:20260830T090000 +DTEND;TZID=Custom/Office:20260830T100000 +SUMMARY:Custom zone event +END:VEVENT +'''), + ), + ); + + final report = await importService.importPreview( + preview: preview, + destination: (await importService.writableDestinations()).single, + ); + + expect(report.queued, 0); + expect(report.unsupportedRecurrenceSets, hasLength(1)); + expect( + report.unsupportedRecurrenceSets.single.reason, + contains('custom timezone'), + ); + expect(await database.select(database.calendarEvents).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + + test('writable destinations exclude read-only and WebCal sources', () async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'webcal-account-sub', + provider: 'webcal', + authority: 'https://feed.example.test', + providerAccountId: 'fingerprint', + credentialKind: 'webcal_subscription', + authState: const Value('signed_in'), + calendarsEnabled: const Value(true), + tasksEnabled: const Value(false), + grantedScopes: const Value(''), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'webcal-calendar-sub', + accountId: 'webcal-account-sub', + provider: 'webcal', + providerCalendarId: 'sub', + summary: 'Subscription', + readOnly: const Value(true), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'read-only-google', + accountId: 'google-account', + provider: 'google', + providerCalendarId: 'readonly', + summary: 'Read only', + readOnly: const Value(true), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + + expect( + (await importService.writableDestinations()).map((source) => source.id), + ['google-calendar'], + ); + }); +} + +Future _seedAccountAndSource(AppDatabase database) async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'google-account', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'me@example.test', + credentialKind: 'oauth', + email: const Value('me@example.test'), + authState: const Value('signed_in'), + grantedScopes: const Value(''), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'google-calendar', + accountId: 'google-account', + provider: 'google', + providerCalendarId: 'primary', + summary: 'Calendar', + accessRole: const Value('owner'), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); +} + +const _now = '2026-08-29T00:00:00.000Z'; + +String _calendar(String body) => + '''BEGIN:VCALENDAR\r +VERSION:2.0\r +PRODID:-//BusyMax Import Test//EN\r +${body.trim().replaceAll('\n', '\r\n')}\r +END:VCALENDAR\r +'''; diff --git a/test/ical/ical_ingestion_test.dart b/test/ical/ical_ingestion_test.dart new file mode 100644 index 00000000..800ae9d5 --- /dev/null +++ b/test/ical/ical_ingestion_test.dart @@ -0,0 +1,344 @@ +import 'dart:convert'; + +import 'package:busymax/src/dav/ical/ical_semantics.dart'; +import 'package:busymax/src/ical/ical_event_projection.dart'; +import 'package:busymax/src/ical/ical_ingestion.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('shared iCalendar ingestion', () { + test('groups multiple UIDs and retains recurrence exceptions', () { + final result = IcalIngestion.parseString( + _calendar(''' +BEGIN:VEVENT +UID:first +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +RRULE:FREQ=DAILY;COUNT=2 +SUMMARY:Master +END:VEVENT +BEGIN:VEVENT +UID:first +RECURRENCE-ID:20260831T160000Z +DTSTART:20260831T180000Z +DTEND:20260831T190000Z +SUMMARY:Moved occurrence +END:VEVENT +BEGIN:VEVENT +UID:second +DTSTART;VALUE=DATE:20260901 +DTEND;VALUE=DATE:20260902 +SUMMARY:All day +END:VEVENT +'''), + policy: IcalIngestionPolicy.webCal, + ); + + expect(result.recurrenceSets.map((set) => set.uid), ['first', 'second']); + expect(result.recurrenceSets.first.semantic.components, hasLength(2)); + expect( + result.recurrenceSets.first.semantic.components.last.recurrenceIdKey, + '20260831T160000Z', + ); + }); + + test('rejects duplicate logical components without choosing one', () { + final source = _calendar(''' +${_event('duplicate', 'First')} +${_event('duplicate', 'Second')} +'''); + + expect( + () => IcalIngestion.parseString( + source, + policy: IcalIngestionPolicy.webCal, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'IcalDuplicateLogicalComponent', + ), + ), + ); + final imported = IcalIngestion.parseString( + source, + policy: IcalIngestionPolicy.fileImport, + ); + expect(imported.recurrenceSets, isEmpty); + expect(imported.rejectedEvents.single.uid, 'duplicate'); + }); + + test('normalizes recurrence identifiers before duplicate detection', () { + final source = _calendar(''' +BEGIN:VEVENT +UID:duplicate-date +DTSTART;VALUE=DATE:20260830 +DTEND;VALUE=DATE:20260831 +RRULE:FREQ=DAILY;COUNT=2 +SUMMARY:Master +END:VEVENT +BEGIN:VEVENT +UID:duplicate-date +RECURRENCE-ID:20260831 +DTSTART;VALUE=DATE:20260831 +DTEND;VALUE=DATE:20260901 +SUMMARY:First exception +END:VEVENT +BEGIN:VEVENT +UID:duplicate-date +RECURRENCE-ID;VALUE=DATE:20260831 +DTSTART;VALUE=DATE:20260831 +DTEND;VALUE=DATE:20260901 +SUMMARY:Duplicate exception +END:VEVENT +'''); + + expect( + () => IcalIngestion.parseString( + source, + policy: IcalIngestionPolicy.webCal, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'IcalDuplicateLogicalComponent', + ), + ), + ); + }); + + test('retains VTIMEZONE in every semantic recurrence set', () { + final result = IcalIngestion.parseString( + _calendar(''' +BEGIN:VTIMEZONE +TZID:Custom/Office +BEGIN:STANDARD +DTSTART:19700101T000000 +TZOFFSETFROM:-0800 +TZOFFSETTO:-0800 +TZNAME:PST +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +UID:zoned +DTSTART;TZID=Custom/Office:20260830T090000 +DTEND;TZID=Custom/Office:20260830T100000 +SUMMARY:Zoned +END:VEVENT +'''), + policy: IcalIngestionPolicy.webCal, + ); + + expect( + result.recurrenceSets.single.semantic.document.calendarComponents.where( + (component) => component.name == 'VTIMEZONE', + ), + hasLength(1), + ); + final projected = IcalEventProjector().project( + result.recurrenceSets.single, + rangeStartUtc: DateTime.utc(2026, 8, 1), + rangeEndUtc: DateTime.utc(2026, 9, 30), + transport: 'webcal', + ); + expect(projected.single.startTimeZone, 'Custom/Office'); + expect( + (jsonDecode(projected.single.rawJson) as Map)['startUtc'], + '2026-08-30T17:00:00.000Z', + ); + }); + + test('preserves UTC, floating, and all-day value semantics', () { + final result = IcalIngestion.parseString( + _calendar(''' +${_event('utc', 'UTC')} +BEGIN:VEVENT +UID:floating +DTSTART:20260830T090000 +DTEND:20260830T100000 +SUMMARY:Floating +END:VEVENT +BEGIN:VEVENT +UID:date +DTSTART;VALUE=DATE:20260830 +DTEND;VALUE=DATE:20260831 +SUMMARY:Date +END:VEVENT +'''), + policy: IcalIngestionPolicy.webCal, + ); + final projections = [ + for (final set in result.recurrenceSets) + ...IcalEventProjector().project( + set, + rangeStartUtc: DateTime.utc(2026, 8, 1), + rangeEndUtc: DateTime.utc(2026, 9, 30), + transport: 'webcal', + ), + ]; + + final byUid = {for (final event in projections) event.uid: event}; + expect(byUid['utc']!.startTimeZone, 'UTC'); + expect(byUid['floating']!.startTimeZone, isNull); + expect(byUid['floating']!.startDateTime, '2026-08-30T09:00:00'); + expect(byUid['date']!.allDay, isTrue); + expect(byUid['date']!.startDate, '2026-08-30'); + }); + + test('uses fail-fast WebCal and partial file-import policies', () { + final source = _calendar(''' +${_event('valid', 'Valid')} +BEGIN:VEVENT +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Missing UID +END:VEVENT +'''); + + expect( + () => IcalIngestion.parseString( + source, + policy: IcalIngestionPolicy.webCal, + ), + throwsA(isA()), + ); + final imported = IcalIngestion.parseString( + source, + policy: IcalIngestionPolicy.fileImport, + ); + expect(imported.recurrenceSets.single.uid, 'valid'); + expect(imported.rejectedEvents, hasLength(1)); + }); + + test('accepts a valid zero-event snapshot but rejects no components', () { + final empty = IcalIngestion.parseString( + _calendar(''' +BEGIN:VTODO +UID:task +DTSTAMP:20260829T000000Z +SUMMARY:Not projected +END:VTODO +'''), + policy: IcalIngestionPolicy.webCal, + ); + expect(empty.recurrenceSets, isEmpty); + expect(empty.unprojectedComponentTypes, contains('VTODO')); + + expect( + () => IcalIngestion.parseString( + 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//BusyMax Test//EN\r\nEND:VCALENDAR\r\n', + policy: IcalIngestionPolicy.webCal, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'IcalCalendarHasNoComponents', + ), + ), + ); + }); + + test('enforces envelope, BOM, strict UTF-8, and size invariants', () { + final bom = [ + 0xef, + 0xbb, + 0xbf, + ...utf8.encode(_calendar(_event('bom', 'BOM'))), + ]; + expect( + IcalIngestion.parseBytes( + bom, + policy: IcalIngestionPolicy.webCal, + ).recurrenceSets.single.uid, + 'bom', + ); + expect( + () => IcalIngestion.parseBytes([ + 0xc3, + 0x28, + ], policy: IcalIngestionPolicy.webCal), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'IcalInvalidUtf8', + ), + ), + ); + expect( + () => IcalIngestion.parseBytes( + List.filled(icalIngestionDecodedBodyLimit + 1, 0x20), + policy: IcalIngestionPolicy.webCal, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'IcalBodyTooLarge', + ), + ), + ); + }); + + test('sanitized snapshot removes secret-bearing properties', () { + final secret = Uri.parse('https://example.test/private.ics?token=secret'); + final target = Uri.parse('https://cdn.example.test/current.ics?key=two'); + final result = IcalIngestion.parseString( + _calendar(''' +SOURCE:${secret.toString()} +BEGIN:VEVENT +UID:safe +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Safe +DESCRIPTION;ALTREP="${secret.toString()}":Quoted +DESCRIPTION;ALTREP="https://cdn.example.test/current.ics? + key=two":Folded +DESCRIPTION;ALTREP="${secret.toString()}^nprivate":Escaped +END:VEVENT +'''), + policy: IcalIngestionPolicy.webCal, + ); + final snapshot = sanitizedCanonicalSnapshot( + result.document, + secretValues: [secret.toString(), target.toString()], + ); + + expect(snapshot, isNot(contains(secret.toString()))); + expect(snapshot, isNot(contains(target.toString()))); + expect(snapshot, isNot(contains('ALTREP'))); + expect(snapshot, contains('UID:safe')); + }); + + test('existing DAV semantic parser keeps one-UID invariant', () { + expect( + () => IcalSemanticDocument.parse( + _calendar(''' +${_event('one', 'One')} +${_event('two', 'Two')} +'''), + ), + throwsA(anything), + ); + }); + }); +} + +String _calendar(String components) => + '''BEGIN:VCALENDAR\r +VERSION:2.0\r +PRODID:-//BusyMax Test//EN\r +${components.trim().replaceAll('\n', '\r\n')}\r +END:VCALENDAR\r +'''; + +String _event(String uid, String summary) => + '''BEGIN:VEVENT +UID:$uid +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:$summary +END:VEVENT'''; diff --git a/test/microsoft_calendar/microsoft_calendar_api_client_test.dart b/test/microsoft_calendar/microsoft_calendar_api_client_test.dart index 7095f232..21028306 100644 --- a/test/microsoft_calendar/microsoft_calendar_api_client_test.dart +++ b/test/microsoft_calendar/microsoft_calendar_api_client_test.dart @@ -7,6 +7,31 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; void main() { + test('calendar update sends the documented Microsoft color enum', () async { + late http.Request captured; + final client = _client((request) { + captured = request; + return _json({ + 'id': 'cal-1', + 'name': 'Work', + 'color': 'lightBlue', + 'hexColor': '#0078D4', + 'canEdit': true, + }); + }); + + final source = await client.updateCalendar( + 'cal-1', + const CalendarMutation(colorId: 'lightBlue'), + ); + + expect(captured.method, 'PATCH'); + expect(captured.url.path, '/v1.0/me/calendars/cal-1'); + expect(jsonDecode(captured.body), {'color': 'lightBlue'}); + expect(source.colorId, 'lightBlue'); + expect(source.backgroundColor, '#0078D4'); + }); + test( 'event create, update, and delete use Microsoft Graph endpoints', () async { diff --git a/test/microsoft_calendar/microsoft_calendar_mapper_test.dart b/test/microsoft_calendar/microsoft_calendar_mapper_test.dart index 840bab74..0ff91a9b 100644 --- a/test/microsoft_calendar/microsoft_calendar_mapper_test.dart +++ b/test/microsoft_calendar/microsoft_calendar_mapper_test.dart @@ -11,10 +11,12 @@ void main() { 'canEdit': true, 'hexColor': '#123456', 'color': 'lightBlue', + 'isRemovable': false, }); expect(source.backgroundColor, '#123456'); expect(source.colorId, 'lightBlue'); + expect(source.isRemovable, isFalse); }); test( @@ -146,4 +148,12 @@ void main() { expect(body['isOnlineMeeting'], isTrue); expect(body['onlineMeetingProvider'], 'teamsForBusiness'); }); + + test('Microsoft event create serializes the transaction ID', () { + final body = microsoftEventMutationToJson( + const CalendarEventMutation(transactionId: 'transaction-1'), + ); + + expect(body['transactionId'], 'transaction-1'); + }); } diff --git a/test/platform/busymax_tray_service_test.dart b/test/platform/busymax_tray_service_test.dart index c3e1862b..6541873e 100644 --- a/test/platform/busymax_tray_service_test.dart +++ b/test/platform/busymax_tray_service_test.dart @@ -1,277 +1,292 @@ +import 'dart:async'; import 'dart:io'; +import 'package:busymax/src/features/tray/domain/tray_presentation.dart'; import 'package:busymax/src/platform/busymax_tray_service.dart'; import 'package:dbus/dbus.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:xdg_status_notifier_item/xdg_status_notifier_item.dart'; void main() { - test('tray menu contains only app, agenda, and exit actions', () async { - var openedApp = false; - var openedAgenda = false; - var quit = false; + test('every presentation retains the fixed hierarchy and ID sequence', () { + final presentations = [ + _presentation(), + _presentation(events: [_row('one')]), + _presentation( + events: [_row('one'), _row('two'), _row('three')], + taskSummary: '3 tasks due today', + ), + _presentation( + offline: true, + canCreateEvent: false, + canCreateTask: false, + canSynchronize: false, + ), + ]; + + for (final presentation in presentations) { + final menu = buildBusyMaxTrayMenu( + presentation: presentation, + actions: _Actions().value, + ); + expect(menu.id, BusyMaxTrayMenuIds.root); + expect( + menu.children.map((item) => item.id), + BusyMaxTrayMenuIds.orderedChildren, + ); + expect(menu.children, hasLength(18)); + expect( + menu.children + .where((item) => item.type == 'separator') + .map((item) => item.id), + [2, 5, 13, 16], + ); + expect( + menu.children.where((item) => item.type == 'separator'), + everyElement( + isA().having((item) => item.visible, 'visible', isTrue), + ), + ); + expect(_item(menu, BusyMaxTrayMenuIds.today).enabled, isFalse); + expect(_item(menu, BusyMaxTrayMenuIds.today).visible, isTrue); + } + }); - final menu = buildBusyMaxTrayMenu( - labels: _labels, - onOpenBusyMax: () async { - openedApp = true; - }, - onOpenAgenda: () async { - openedAgenda = true; - }, - onQuit: () async { - quit = true; - }, + test('unused slots become invisible and task and empty rows are correct', () { + final oneEvent = buildBusyMaxTrayMenu( + presentation: _presentation(events: [_row('event')]), + actions: _Actions().value, ); + expect(_item(oneEvent, 7).visible, isTrue); + expect(_item(oneEvent, 8).visible, isFalse); + expect(_item(oneEvent, 9).visible, isFalse); + expect(_item(oneEvent, 10).visible, isFalse); + expect(_item(oneEvent, 11).visible, isFalse); + + final taskOnly = buildBusyMaxTrayMenu( + presentation: _presentation(taskSummary: '1 task due today'), + actions: _Actions().value, + ); + expect(_item(taskOnly, 10).visible, isTrue); + expect(_item(taskOnly, 10).enabled, isTrue); + expect(_item(taskOnly, 11).visible, isFalse); - expect(menu.children, hasLength(3)); - expect(menu.id, 0); - expect(menu.children.map((item) => item.id), [1, 2, 3]); - expect(menu.children.map((item) => item.label), [ - 'Open BusyMax', - 'Agenda', - 'Exit', - ]); - expect(menu.enabled, isTrue); - expect(menu.visible, isTrue); - expect(menu.children.every((item) => item.enabled == true), isTrue); - expect(menu.children.every((item) => item.visible == true), isTrue); - expect(menu.children.every((item) => item.children.isEmpty), isTrue); - - await menu.children[0].onClicked?.call(); - await menu.children[1].onClicked?.call(); - await menu.children[2].onClicked?.call(); - - expect(openedApp, isTrue); - expect(openedAgenda, isTrue); - expect(quit, isTrue); + final empty = buildBusyMaxTrayMenu( + presentation: _presentation(), + actions: _Actions().value, + ); + expect(_item(empty, 10).visible, isFalse); + expect(_item(empty, 11).visible, isTrue); + expect(_item(empty, 11).enabled, isFalse); }); - test('tray menu id 2 invokes Agenda callback', () async { - var openedAgenda = false; - final menu = buildBusyMaxTrayMenu( - labels: _labels, - onOpenBusyMax: () async {}, - onOpenAgenda: () async { - openedAgenda = true; + test('creation and synchronization enabled states follow presentation', () { + final disabled = buildBusyMaxTrayMenu( + presentation: _presentation( + canCreateEvent: false, + canCreateTask: false, + canSynchronize: false, + ), + actions: _Actions().value, + ); + expect(_item(disabled, 3).enabled, isFalse); + expect(_item(disabled, 4).enabled, isFalse); + expect(_item(disabled, 14).enabled, isFalse); + expect(_item(disabled, 15).enabled, isFalse); + + final enabled = buildBusyMaxTrayMenu( + presentation: _presentation(), + actions: _Actions().value, + ); + expect(_item(enabled, 3).enabled, isTrue); + expect(_item(enabled, 4).enabled, isTrue); + expect(_item(enabled, 14).enabled, isTrue); + }); + + test('event callback is rebound to the current slot identity', () async { + final opened = []; + var next = _presentation(events: [_row('first')]); + final actions = _Actions( + onOpenEvent: (event) async { + opened.add(event.eventId); }, - onQuit: () async {}, ); + final controller = BusyMaxTrayMenuController( + initialPresentation: next, + loadPresentation: () async => next, + actions: actions.value, + ); + final object = DBusMenuObject( + DBusObjectPath(busyMaxTrayMenuPath), + controller.menu, + ); + controller.attachUpdater((menu, presentation) => object.update(menu)); - final agendaItem = menu.children.singleWhere((item) => item.id == 2); - await agendaItem.onClicked?.call(); + await _click(object, BusyMaxTrayMenuIds.firstEvent); + next = _presentation(events: [_row('second')]); + expect(await controller.refresh(), isTrue); + await _click(object, BusyMaxTrayMenuIds.firstEvent); - expect(agendaItem.label, 'Agenda'); - expect(agendaItem.children, isEmpty); - expect(openedAgenda, isTrue); + expect(opened, ['first', 'second']); }); - test('offline tray menu exposes a disabled connectivity status', () { - final menu = buildBusyMaxTrayMenu( - labels: _labels, - offline: true, - onOpenBusyMax: () async {}, - onOpenAgenda: () async {}, - onQuit: () async {}, + test( + 'AboutToShow updates properties and unchanged loads return false', + () async { + var loads = 0; + var next = _presentation(); + final controller = BusyMaxTrayMenuController( + initialPresentation: next, + loadPresentation: () async { + loads += 1; + return next; + }, + actions: _Actions().value, + ); + final object = DBusMenuObject( + DBusObjectPath(busyMaxTrayMenuPath), + controller.menu, + ); + var updates = 0; + controller.attachUpdater((menu, presentation) { + updates += 1; + return object.update(menu); + }); + + final unchanged = await _aboutToShow(object); + expect(unchanged, isFalse); + expect(updates, 0); + next = _presentation(events: [_row('updated')]); + final changed = await _aboutToShow(object); + expect(changed, isTrue); + expect(updates, 1); + final label = await _property( + object, + BusyMaxTrayMenuIds.firstEvent, + 'label', + ); + expect(label.asString(), '10:30 · updated'); + expect(loads, 2); + }, + ); + + test('simultaneous presentation loads are coalesced', () async { + var loads = 0; + final completer = Completer(); + final controller = BusyMaxTrayMenuController( + initialPresentation: _presentation(), + loadPresentation: () { + loads += 1; + return completer.future; + }, + actions: _Actions().value, ); - expect(menu.children.map((item) => item.id), [4, 1, 2, 3]); - expect( - menu.children.first.label, - 'Offline — Changes will sync when connected.', - ); - expect(menu.children.first.enabled, isFalse); - expect(menu.children.first.visible, isTrue); + final first = controller.refresh(); + final second = controller.refresh(); + completer.complete(_presentation(events: [_row('loaded')])); + + expect(await first, isTrue); + expect(await second, isTrue); + expect(loads, 1); }); - test('offline tray icon is a bundled grayscale status asset', () { - final asset = File('assets/branding/busymax-logo-offline.svg'); - final pubspec = File('pubspec.yaml').readAsStringSync(); + test('load failure retains the previous menu and core actions', () async { + final failures = []; + final initial = _presentation(events: [_row('retained')]); + final controller = BusyMaxTrayMenuController( + initialPresentation: initial, + loadPresentation: () async => throw StateError('local load failed'), + actions: _Actions().value, + onLoadFailure: failures.add, + ); - expect(asset.existsSync(), isTrue); - final source = asset.readAsStringSync(); - expect(source, contains('BusyMax offline')); - expect(source, isNot(contains('#950BfF'))); - expect(pubspec, contains('assets/branding/busymax-logo-offline.svg')); + expect(await controller.refresh(), isFalse); + expect(controller.presentation, initial); + expect(_item(controller.menu, 1).enabled, isTrue); + expect(_item(controller.menu, 17).enabled, isTrue); + expect(_item(controller.menu, 18).enabled, isTrue); + expect(failures.single, isA()); }); - test('status notifier presentation updates before registration', () async { - final bus = DBusClient( - DBusAddress.unix(path: '/tmp/busymax-status-notifier-test-bus'), + test('AboutToShow performs no synchronization', () async { + var syncs = 0; + final actions = _Actions(onSynchronize: () async => syncs += 1); + final controller = BusyMaxTrayMenuController( + initialPresentation: _presentation(), + loadPresentation: () async => _presentation(), + actions: actions.value, ); - final client = StatusNotifierItemClient( - id: busyMaxApplicationId, - title: 'BusyMax', - iconName: 'busymax-color', - toolTipTitle: 'BusyMax', - toolTipDescription: '', - menu: buildBusyMaxTrayMenu( - labels: _labels, - onOpenBusyMax: () async {}, - onOpenAgenda: () async {}, - onQuit: () async {}, - ), - bus: bus, - ); - addTearDown(bus.close); - - await client.updatePresentation( - title: 'BusyMax — Offline', - iconName: 'busymax-offline', - toolTipTitle: 'BusyMax — Offline', - toolTipDescription: 'Changes will sync when connected.', + final object = DBusMenuObject( + DBusObjectPath(busyMaxTrayMenuPath), + controller.menu, ); - expect(client.title, 'BusyMax — Offline'); - expect(client.iconName, 'busymax-offline'); - expect(client.toolTipTitle, 'BusyMax — Offline'); - expect(client.toolTipDescription, 'Changes will sync when connected.'); - }); + await _aboutToShow(object); - test('application id uses Busystack reverse DNS id', () { - expect(busyMaxApplicationId, 'io.busystack.busymax'); - expect(busyMaxTrayMenuPath, '/StatusNotifierItem/menu'); + expect(syncs, 0); }); - test('Linux desktop identity matches the displayed BusyMax window', () { - final desktop = File( - 'linux/io.busystack.busymax.desktop', - ).readAsStringSync(); - final cmake = File('linux/CMakeLists.txt').readAsStringSync(); - final runner = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(desktop, contains('Name=BusyMax')); - expect(desktop, contains('Icon=io.busystack.busymax')); - expect(desktop, contains('StartupWMClass=io.busystack.busymax')); - expect( - cmake, - contains(r'"${CMAKE_CURRENT_SOURCE_DIR}/io.busystack.busymax.desktop"'), - ); - expect( - cmake, - contains( - r'"${CMAKE_CURRENT_SOURCE_DIR}/io.busystack.busymax.metainfo.xml"', - ), - ); - expect( - cmake, - contains( - r'"${CMAKE_CURRENT_SOURCE_DIR}/../assets/branding/busymax-logo.svg"', - ), + test('Activate restores and SecondaryActivate opens today agenda', () async { + var shown = 0; + var agendas = 0; + final actions = _Actions( + onShow: () async => shown += 1, + onAgenda: () async => agendas += 1, ); - expect(cmake, contains('share/icons/hicolor/scalable/apps')); - expect(cmake, contains('RENAME "io.busystack.busymax.svg"')); - const pngExtension = 'png'; - expect(cmake, isNot(contains('.$pngExtension'))); - final iconFileName = - '${busyMaxApplicationId.split('.').last}.$pngExtension'; - for (final size in [64, 128, 256]) { - final path = [ - 'linux', - 'runner', - 'resources', - 'icons', - '${size}x$size', - 'apps', - iconFileName, - ].join(Platform.pathSeparator); - expect(File(path).existsSync(), isFalse); - } - expect( - runner, - contains( - 'gtk_window_set_wmclass(window, APPLICATION_ID, APPLICATION_ID);', - ), - ); - expect(runner, contains('static_cast(0)')); - expect(runner, isNot(contains('G_APPLICATION_NON_UNIQUE'))); - expect(runner, contains('static void restore_main_window')); - expect(runner, contains('gtk_widget_show(GTK_WIDGET(self->main_window));')); - expect(runner, contains('gtk_window_deiconify(self->main_window);')); - expect( - runner, - contains( - 'gtk_window_present_with_time(self->main_window, GDK_CURRENT_TIME);', - ), - ); - expect(runner, contains('restore_main_window(self);')); - expect(runner, contains('BusyMax native showWindow method call received')); - expect( - runner, - contains('BusyMax native showWindow invoked: main_window_null=%s'), - ); - expect(runner, contains('BusyMax native hideWindow invoked')); - expect( - runner, - contains('BusyMax native hideWindow invocation: source=delete-event'), - ); - expect(runner, contains('BusyMax native quit invocation: method=quitApp')); - expect( - runner, - contains( - 'if (application_icon == nullptr) {\n' - ' gtk_window_set_icon_name(window, APPLICATION_ID);\n' - ' }', + final service = BusyMaxTrayService( + configuration: BusyMaxTrayServiceConfiguration( + initialPresentation: _presentation(), + loadPresentation: () async => _presentation(), + actions: actions.value, ), ); - }); - test('agenda action delegates navigation to the app', () { + await service.handleActivate(); + await service.handleSecondaryActivate(); + + expect(shown, 1); + expect(agendas, 1); final source = File( 'lib/src/platform/busymax_tray_service.dart', ).readAsStringSync(); + expect(source, isNot(contains('onContextMenu:'))); + }); - expect(source, contains('Future _showAgenda()')); - expect(source, contains('return _onOpenAgenda();')); - expect(source, isNot(contains('BusyMaxTrayAgendaMenu'))); - expect(source, isNot(contains('BusyMaxTrayAgendaEntry'))); - expect(source, contains('StatusNotifierItem activation callback')); - expect(source, contains('Tray service start requested')); - expect(source, contains('Tray initialization starting')); - expect(source, contains('StatusNotifier registration succeeded')); - expect(source, contains('StatusNotifier registration failed')); - expect(source, contains('DBus menu creation starting')); - expect(source, contains('DBus menu creation completed')); - expect(source, contains('menuPath: DBusObjectPath(busyMaxTrayMenuPath)')); - expect(source, contains('itemIsMenu: true')); - expect(source, contains('Tray menu DBus verification succeeded')); - expect(source, contains('Tray callback fired')); - expect(source, contains('Tray menu "Open BusyMax" callback')); - expect(source, contains('Tray menu "Agenda" callback')); - expect(source, contains('Tray menu "Quit" callback')); - expect(source, contains('return _windowService.showWindow();')); - expect( - source, - isNot( - contains( - 'await _windowService.showWindow();\n' - ' await _onOpenAgenda();', - ), - ), + test('Quit invokes the existing application quit callback', () async { + var quits = 0; + final menu = buildBusyMaxTrayMenu( + presentation: _presentation(), + actions: _Actions(onQuit: () async => quits += 1).value, ); - expect(source, isNot(contains('onBeforeQuit'))); + + await _item(menu, BusyMaxTrayMenuIds.quitBusyMax).onClicked?.call(); + + expect(quits, 1); }); - test('DBus menu exposes layout, properties, and routes stable IDs', () async { - var openedApp = false; - var quit = false; - final logs = []; + test('a disappeared event fails safely when its row is activated', () async { + final menu = buildBusyMaxTrayMenu( + presentation: _presentation(events: [_row('removed')]), + actions: _Actions( + onOpenEvent: (_) async => throw StateError('event disappeared'), + ).value, + ); + await expectLater( + _item(menu, BusyMaxTrayMenuIds.firstEvent).onClicked?.call(), + completes, + ); + }); + + test('DBus layout exports all fixed IDs and properties', () async { final object = DBusMenuObject( DBusObjectPath(busyMaxTrayMenuPath), buildBusyMaxTrayMenu( - labels: _labels, - onOpenBusyMax: () async { - openedApp = true; - }, - onOpenAgenda: () async {}, - onQuit: () async { - quit = true; - }, + presentation: _presentation(events: [_row('event')]), + actions: _Actions().value, ), - diagnosticLog: logs.add, ); - final layout = await object.handleMethodCall( DBusMethodCall( sender: 'test', @@ -284,191 +299,221 @@ void main() { ], ), ); - expect(layout, isA()); final root = layout.returnValues[1].asStruct(); - expect(root[0].asInt32(), 0); final children = root[2] .asArray() .map((child) => child.asVariant().asStruct()) .toList(); - expect(children.map((child) => child[0].asInt32()).toList(), [1, 2, 3]); - for (final child in children) { - final properties = child[1].asStringVariantDict(); - expect(properties['label']?.asString(), isNotEmpty); - expect(properties['enabled']?.asBoolean(), isTrue); - expect(properties['visible']?.asBoolean(), isTrue); - expect(properties, isNot(contains('children-display'))); - } - final groupProperties = await object.handleMethodCall( - DBusMethodCall( - sender: 'test', - interface: 'com.canonical.dbusmenu', - name: 'GetGroupProperties', - values: [ - DBusArray.int32([1, 2, 3]), - DBusArray.string(const []), - ], - ), - ); - expect(groupProperties, isA()); - final groupedItems = groupProperties.returnValues.single.asArray(); - expect(groupedItems.map((item) => item.asStruct()[0].asInt32()).toList(), [ - 1, - 2, - 3, - ]); - - final openProperties = groupedItems.first - .asStruct()[1] - .asStringVariantDict(); - expect(openProperties['label']?.asString(), 'Open BusyMax'); - expect(openProperties['enabled']?.asBoolean(), isTrue); - expect(openProperties['visible']?.asBoolean(), isTrue); - - final labelProperty = await object.handleMethodCall( - DBusMethodCall( - sender: 'test', - interface: 'com.canonical.dbusmenu', - name: 'GetProperty', - values: [const DBusInt32(1), const DBusString('label')], - ), + expect(root[0].asInt32(), BusyMaxTrayMenuIds.root); + expect( + children.map((child) => child[0].asInt32()), + BusyMaxTrayMenuIds.orderedChildren, ); - expect(labelProperty, isA()); expect( - labelProperty.returnValues.single.asVariant().asString(), - 'Open BusyMax', + children + .singleWhere( + (child) => child[0].asInt32() == BusyMaxTrayMenuIds.today, + )[1] + .asStringVariantDict()['enabled'] + ?.asBoolean(), + isFalse, ); + }); - final aboutToShow = await object.handleMethodCall( - DBusMethodCall( - sender: 'test', - interface: 'com.canonical.dbusmenu', - name: 'AboutToShow', - values: [const DBusInt32(1)], - ), - ); - expect(aboutToShow, isA()); - expect(aboutToShow.returnValues.single.asBoolean(), isFalse); + test('offline tray icon remains the bundled grayscale asset', () { + final asset = File('assets/branding/busymax-logo-offline.svg'); + final pubspec = File('pubspec.yaml').readAsStringSync(); - final aboutToShowGroup = await object.handleMethodCall( - DBusMethodCall( - sender: 'test', - interface: 'com.canonical.dbusmenu', - name: 'AboutToShowGroup', - values: [ - DBusArray.int32([1, 2, 99]), - ], - ), - ); - expect(aboutToShowGroup, isA()); - expect(aboutToShowGroup.returnValues[0].asInt32Array(), isEmpty); - expect(aboutToShowGroup.returnValues[1].asInt32Array(), [99]); + expect(asset.existsSync(), isTrue); + expect(asset.readAsStringSync(), contains('BusyMax offline')); + expect(asset.readAsStringSync(), isNot(contains('#950BfF'))); + expect(pubspec, contains('assets/branding/busymax-logo-offline.svg')); + }); - final version = await object.getProperty( - 'com.canonical.dbusmenu', - 'Version', - ); - expect(version, isA()); - expect(version.returnValues.single.asVariant().asUint32(), greaterThan(0)); - final status = await object.getProperty('com.canonical.dbusmenu', 'Status'); - expect(status.returnValues.single.asVariant().asString(), 'normal'); - final textDirection = await object.getProperty( - 'com.canonical.dbusmenu', - 'TextDirection', - ); - expect(textDirection.returnValues.single.asVariant().asString(), 'ltr'); - final iconThemePath = await object.getProperty( - 'com.canonical.dbusmenu', - 'IconThemePath', + test('status notifier presentation can update before registration', () async { + final bus = DBusClient( + DBusAddress.unix(path: '/tmp/busymax-status-notifier-test-bus'), ); - expect( - iconThemePath.returnValues.single.asVariant().asStringArray(), - isEmpty, + final client = StatusNotifierItemClient( + id: busyMaxApplicationId, + title: 'BusyMax', + iconName: 'busymax-color', + toolTipTitle: 'BusyMax', + toolTipDescription: '', + menu: buildBusyMaxTrayMenu( + presentation: _presentation(), + actions: _Actions().value, + ), + bus: bus, ); + addTearDown(bus.close); - final event = await object.handleMethodCall( - DBusMethodCall( - sender: 'test', - interface: 'com.canonical.dbusmenu', - name: 'Event', - values: [ - const DBusInt32(1), - const DBusString('clicked'), - const DBusVariant(DBusInt32(0)), - const DBusUint32(0), - ], - ), + await client.updatePresentation( + title: 'BusyMax — Offline', + iconName: 'busymax-offline', + toolTipTitle: 'BusyMax — Offline', + toolTipDescription: 'Changes will sync when connected.', ); - expect(event, isA()); - expect(openedApp, isTrue); - final eventGroup = await object.handleMethodCall( - DBusMethodCall( - sender: 'test', - interface: 'com.canonical.dbusmenu', - name: 'EventGroup', - values: [ - DBusArray(DBusSignature('(isvu)'), [ - DBusStruct([ - const DBusInt32(3), - const DBusString('clicked'), - const DBusVariant(DBusInt32(0)), - const DBusUint32(0), - ]), - ]), - ], - ), + expect(client.title, 'BusyMax — Offline'); + expect(client.iconName, 'busymax-offline'); + expect(client.toolTipTitle, 'BusyMax — Offline'); + expect(client.toolTipDescription, 'Changes will sync when connected.'); + }); + + test('Linux desktop identity matches the displayed BusyMax window', () { + final desktop = File( + 'linux/io.busystack.busymax.desktop', + ).readAsStringSync(); + final cmake = File('linux/CMakeLists.txt').readAsStringSync(); + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + + expect(busyMaxApplicationId, 'io.busystack.busymax'); + expect(busyMaxTrayMenuPath, '/StatusNotifierItem/menu'); + expect(desktop, contains('Name=BusyMax')); + expect(desktop, contains('Icon=io.busystack.busymax')); + expect(desktop, contains('StartupWMClass=io.busystack.busymax')); + expect( + cmake, + contains(r'"${CMAKE_CURRENT_SOURCE_DIR}/io.busystack.busymax.desktop"'), ); - expect(eventGroup, isA()); - expect(eventGroup.returnValues.single.asInt32Array(), isEmpty); - expect(quit, isTrue); - expect(logs, contains(startsWith('DBusMenu.GetLayout received'))); - expect(logs, contains(startsWith('DBusMenu.GetGroupProperties received'))); - expect(logs, contains(startsWith('DBusMenu.GetProperty received'))); - expect(logs, contains(startsWith('DBusMenu.AboutToShow received'))); - expect(logs, contains(startsWith('DBusMenu.AboutToShowGroup received'))); - expect(logs, contains(startsWith('DBusMenu.Event received'))); - expect(logs, contains(startsWith('DBusMenu.EventGroup received'))); + expect(runner, contains('G_APPLICATION_HANDLES_OPEN')); + expect(runner, isNot(contains('G_APPLICATION_NON_UNIQUE'))); + expect(runner, contains('static void restore_main_window')); }); - test('patched status notifier and DBus menu handle Ubuntu tray calls', () { + test('vendored DBus implementation retains Ubuntu menu support', () { final source = File( 'third_party/xdg_status_notifier_item/lib/src/dbus_menu_object.dart', ).readAsStringSync(); - final statusNotifier = File( + final notifier = File( 'third_party/xdg_status_notifier_item/lib/src/status_notifier_item_client.dart', ).readAsStringSync(); expect(source, contains('var values = event.asStruct();')); expect(source, contains(r'DBusMenu.${methodCall.name} received')); expect(source, contains('GetGroupProperties')); - expect(source, contains('DBusMenu.GetLayout details')); - expect(source, contains('DBusMenu.GetProperty details')); expect(source, contains('DBusMenu.AboutToShow details')); - expect(source, contains('DBusMenu.AboutToShowGroup details')); - expect(source, contains("DBusString('normal')")); - expect(source, contains("DBusString('ltr')")); - expect(source, contains('DBusArray.string(const [])')); expect(source, contains('_itemsById[id]')); - expect(statusNotifier, contains("'/StatusNotifierItem/menu'")); - expect(statusNotifier, contains('org.kde.StatusNotifierItem')); - expect(statusNotifier, contains('org.freedesktop.StatusNotifierItem')); - expect(statusNotifier, contains("DBusIntrospectSignal('NewIcon')")); - expect(statusNotifier, contains("DBusIntrospectSignal('NewToolTip')")); - expect(statusNotifier, contains("DBusSignature('(sa(iiay)ss)')")); - expect(statusNotifier, contains('Future updatePresentation')); - expect( - statusNotifier, - contains(r'StatusNotifierItem.${methodCall.name} received'), - ); + expect(notifier, contains("'/StatusNotifierItem/menu'")); + expect(notifier, contains('org.kde.StatusNotifierItem')); + expect(notifier, contains('Future updatePresentation')); }); } -const _labels = BusyMaxTrayLabels( - openBusyMax: 'Open BusyMax', - agenda: 'Agenda', - quitBusyMax: 'Exit', - offline: 'Offline', - offlineDescription: 'Changes will sync when connected.', -); +DBusMenuItem _item(DBusMenuItem menu, int id) { + return menu.children.singleWhere((item) => item.id == id); +} + +Future _click(DBusMenuObject object, int id) async { + await object.handleMethodCall( + DBusMethodCall( + sender: 'test', + interface: 'com.canonical.dbusmenu', + name: 'Event', + values: [ + DBusInt32(id), + const DBusString('clicked'), + const DBusVariant(DBusInt32(0)), + const DBusUint32(0), + ], + ), + ); +} + +Future _aboutToShow(DBusMenuObject object) async { + final response = await object.handleMethodCall( + DBusMethodCall( + sender: 'test', + interface: 'com.canonical.dbusmenu', + name: 'AboutToShow', + values: [const DBusInt32(BusyMaxTrayMenuIds.root)], + ), + ); + return response.returnValues.single.asBoolean(); +} + +Future _property(DBusMenuObject object, int id, String name) async { + final response = await object.handleMethodCall( + DBusMethodCall( + sender: 'test', + interface: 'com.canonical.dbusmenu', + name: 'GetProperty', + values: [DBusInt32(id), DBusString(name)], + ), + ); + return response.returnValues.single.asVariant(); +} + +BusyMaxTrayEventRowPresentation _row(String id) { + return BusyMaxTrayEventRowPresentation( + event: BusyMaxTrayEventEntry( + eventId: id, + accountId: 'account', + calendarSourceId: 'calendar', + title: id, + start: DateTime(2026, 8, 29, 10, 30), + end: DateTime(2026, 8, 29, 11), + allDay: false, + ), + label: '10:30 · $id', + ); +} + +BusyMaxTrayMenuPresentation _presentation({ + List events = const [], + String? taskSummary, + bool canCreateEvent = true, + bool canCreateTask = true, + bool canSynchronize = true, + bool offline = false, +}) { + return BusyMaxTrayMenuPresentation( + showBusyMaxLabel: 'Show BusyMax', + newEventLabel: 'New event…', + newTaskLabel: 'New task…', + todayLabel: 'Today', + eventRows: events, + taskSummaryLabel: taskSummary, + emptyStateLabel: 'Nothing else today', + openTodayAgendaLabel: 'Open today’s agenda', + syncNowLabel: 'Sync now', + synchronizationStatusLabel: offline + ? 'Offline — Changes will sync when connected.' + : 'Last synced just now', + settingsLabel: 'Settings', + quitBusyMaxLabel: 'Quit BusyMax', + offlineTitle: 'Offline', + offlineDescription: 'Changes will sync when connected.', + canCreateEvent: canCreateEvent, + canCreateTask: canCreateTask, + canSynchronize: canSynchronize, + offline: offline, + ); +} + +final class _Actions { + _Actions({ + Future Function()? onShow, + Future Function(BusyMaxTrayEventEntry event)? onOpenEvent, + Future Function()? onAgenda, + Future Function()? onSynchronize, + Future Function()? onQuit, + }) : value = BusyMaxTrayActions( + showBusyMax: onShow ?? _noop, + newEvent: _noop, + newTask: _noop, + openEvent: onOpenEvent ?? _noopEvent, + openTasksDueToday: _noop, + openTodayAgenda: onAgenda ?? _noop, + synchronize: onSynchronize ?? _noop, + openSettings: _noop, + quitBusyMax: onQuit ?? _noop, + ); + + final BusyMaxTrayActions value; +} + +Future _noop() async {} +Future _noopEvent(BusyMaxTrayEventEntry event) async {} diff --git a/test/platform/common/desktop_activation_test.dart b/test/platform/common/desktop_activation_test.dart new file mode 100644 index 00000000..644741fb --- /dev/null +++ b/test/platform/common/desktop_activation_test.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; + +import 'package:busymax/src/platform/common/desktop_services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('DesktopActivation', () { + test('round-trips every supported activation shape', () { + const values = [ + DesktopActivation(kind: DesktopActivationKind.normalLaunch), + DesktopActivation(kind: DesktopActivationKind.startMinimized), + DesktopActivation( + kind: DesktopActivationKind.icsFile, + value: r'C:\Users\Renée\Calendar\予定.ics', + ), + DesktopActivation( + kind: DesktopActivationKind.webCal, + value: 'webcal://calendar.example.test/public.ics?token=opaque', + ), + DesktopActivation( + kind: DesktopActivationKind.notification, + action: 'snooze', + payload: { + 'notificationScheduleId': 'schedule-42', + 'itemKind': 'event', + 'accountId': 'account-7', + 'itemId': 'event-9', + }, + ), + ]; + + for (final value in values) { + final decoded = DesktopActivation.tryDecode(value.encode()); + expect(decoded, isNotNull); + expect(decoded!.toJson(), value.toJson()); + } + }); + + test('rejects malformed, unknown, and oversized input', () { + expect(DesktopActivation.tryDecode('{'), isNull); + expect( + DesktopActivation.tryDecode( + jsonEncode({'version': 1, 'kind': 'shellCommand', 'value': 'calc'}), + ), + isNull, + ); + expect( + DesktopActivation.tryDecode( + jsonEncode({ + 'version': 1, + 'kind': 'normalLaunch', + 'unexpected': 'ignored fields must not relax the model', + }), + ), + isNull, + ); + expect( + DesktopActivation.tryDecode( + jsonEncode({ + 'version': 1, + 'kind': 'icsFile', + 'value': r'C:\Temp\notes.txt', + }), + ), + isNull, + ); + expect( + DesktopActivation.tryDecode( + jsonEncode({ + 'version': 1, + 'kind': 'webCal', + 'value': 'https://calendar.example.test/feed.ics', + }), + ), + isNull, + ); + expect( + DesktopActivation.tryDecode( + 'x' * (DesktopActivation.maximumEncodedBytes + 1), + ), + isNull, + ); + }); + + test('requires safe notification action and stable schedule identity', () { + for (final action in const ['default', 'open', 'snooze', 'dismiss']) { + expect( + DesktopActivation( + kind: DesktopActivationKind.notification, + action: action, + payload: const {'notificationScheduleId': 'schedule-42'}, + ).isValid, + isTrue, + ); + } + expect( + const DesktopActivation( + kind: DesktopActivationKind.notification, + action: 'run', + payload: {'notificationScheduleId': 'schedule-42'}, + ).isValid, + isFalse, + ); + expect( + const DesktopActivation( + kind: DesktopActivationKind.notification, + action: 'open', + payload: {'itemId': 'event-9'}, + ).isValid, + isFalse, + ); + expect( + const DesktopActivation( + kind: DesktopActivationKind.notification, + action: 'open', + payload: { + 'notificationScheduleId': 'schedule-42', + 'command': 'arbitrary commands are not activation data', + }, + ).isValid, + isFalse, + ); + }); + + test('only notification body and Open require a visible window', () { + DesktopActivation notification(String action) => DesktopActivation( + kind: DesktopActivationKind.notification, + action: action, + payload: const {'notificationScheduleId': 'schedule-42'}, + ); + + expect(notification('default').requiresVisibleWindow, isTrue); + expect(notification('open').requiresVisibleWindow, isTrue); + expect(notification('snooze').requiresVisibleWindow, isFalse); + expect(notification('dismiss').requiresVisibleWindow, isFalse); + }); + }); +} diff --git a/test/platform/external_calendar_open_service_test.dart b/test/platform/external_calendar_open_service_test.dart new file mode 100644 index 00000000..5dc1226f --- /dev/null +++ b/test/platform/external_calendar_open_service_test.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:busymax/src/platform/external_calendar_open_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('signals readiness and preserves accepted open arrival order', () async { + const channel = MethodChannel(externalCalendarOpenChannelName); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final outbound = []; + messenger.setMockMethodCallHandler(channel, (call) async { + outbound.add(call); + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final service = ExternalCalendarOpenService(channel: channel); + addTearDown(service.dispose); + + await service.initialize(); + expect(outbound.single.method, 'ready'); + final received = service.requests.take(3).toList(); + await _deliver( + messenger, + const MethodCall('openItem', { + 'kind': 'webcal', + 'value': 'webcal://calendar.example.test/feed', + }), + ); + await _deliver( + messenger, + const MethodCall('openItem', {'kind': 'ics', 'value': '/tmp/first.ics'}), + ); + await _deliver( + messenger, + const MethodCall('openItem', {'kind': 'ics', 'value': '/tmp/second.ics'}), + ); + + final requests = await received; + expect(requests.map((request) => request.kind), [ + ExternalCalendarOpenKind.webCal, + ExternalCalendarOpenKind.icsFile, + ExternalCalendarOpenKind.icsFile, + ]); + expect(requests.map((request) => request.value), [ + 'webcal://calendar.example.test/feed', + '/tmp/first.ics', + '/tmp/second.ics', + ]); + }); + + test('ignores unrelated methods, kinds, and empty values', () async { + const channel = MethodChannel(externalCalendarOpenChannelName); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (_) async => null); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final service = ExternalCalendarOpenService(channel: channel); + addTearDown(service.dispose); + await service.initialize(); + final received = []; + final subscription = service.requests.listen(received.add); + addTearDown(subscription.cancel); + + await _deliver(messenger, const MethodCall('unrelated')); + await _deliver( + messenger, + const MethodCall('openItem', {'kind': 'https', 'value': 'https://x'}), + ); + await _deliver( + messenger, + const MethodCall('openItem', {'kind': 'ics', 'value': ''}), + ); + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + }); +} + +Future _deliver(TestDefaultBinaryMessenger messenger, MethodCall call) { + final done = Completer(); + messenger.handlePlatformMessage( + externalCalendarOpenChannelName, + const StandardMethodCodec().encodeMethodCall(call), + (_) => done.complete(), + ); + return done.future; +} diff --git a/test/platform/linux/linux_network_connectivity_monitor_test.dart b/test/platform/linux/linux_network_connectivity_monitor_test.dart new file mode 100644 index 00000000..348519d0 --- /dev/null +++ b/test/platform/linux/linux_network_connectivity_monitor_test.dart @@ -0,0 +1,58 @@ +import 'dart:async'; + +import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; +import 'package:busymax/src/platform/linux/linux_network_connectivity_monitor.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'Snap runtime does not construct the platform connectivity backend', + () async { + var platformFactoryCalls = 0; + final monitor = createLinuxNetworkConnectivityMonitor( + environment: const {'SNAP': '/snap/busymax/current'}, + observedMonitorFactory: () { + platformFactoryCalls += 1; + throw StateError('NetworkManager must not be accessed in a Snap'); + }, + ); + addTearDown(monitor.dispose); + + await monitor.initialize(); + + expect(platformFactoryCalls, 0); + expect(monitor.availability, NetworkAvailability.unknown); + expect(await monitor.canUseNetwork(), isTrue); + }, + ); + + test( + 'unpackaged Linux runtime retains platform connectivity monitoring', + () async { + final changes = StreamController>.broadcast(); + final observedMonitor = NetworkConnectivityMonitor( + checkConnectivity: () async => [ConnectivityResult.none], + connectivityChanges: changes.stream, + ); + var platformFactoryCalls = 0; + final monitor = createLinuxNetworkConnectivityMonitor( + environment: const {}, + observedMonitorFactory: () { + platformFactoryCalls += 1; + return observedMonitor; + }, + ); + addTearDown(() async { + await monitor.dispose(); + await changes.close(); + }); + + await monitor.initialize(); + + expect(platformFactoryCalls, 1); + expect(monitor, same(observedMonitor)); + expect(monitor.availability, NetworkAvailability.offline); + }, + ); +} diff --git a/test/platform/native_menu_service_test.dart b/test/platform/native_menu_service_test.dart index be91c7fe..3b7775a7 100644 --- a/test/platform/native_menu_service_test.dart +++ b/test/platform/native_menu_service_test.dart @@ -43,15 +43,26 @@ void main() { 'sessionId': session.id, 'anchor': {'x': 24.0, 'y': 36.0, 'width': 140.0, 'height': 34.0}, 'entries': [ - {'label': 'Personal', 'enabled': true, 'selected': false}, + { + 'label': 'Personal', + 'enabled': true, + 'role': 'command', + 'selected': false, + }, { 'label': 'Work', 'icon': 'folder-symbolic', 'enabled': true, + 'role': 'command', 'selected': false, 'shortcut': 'Ctrl+W', }, - {'label': 'Archived', 'enabled': false, 'selected': false}, + { + 'label': 'Archived', + 'enabled': false, + 'role': 'command', + 'selected': false, + }, ], 'focusFirst': false, 'preferredPosition': 'bottom', diff --git a/test/platform/windows/windows_activation_service_test.dart b/test/platform/windows/windows_activation_service_test.dart new file mode 100644 index 00000000..657fadf9 --- /dev/null +++ b/test/platform/windows/windows_activation_service_test.dart @@ -0,0 +1,72 @@ +import 'package:busymax/src/platform/common/desktop_services.dart'; +import 'package:busymax/src/platform/windows/windows_activation_service.dart'; +import 'package:busymax/src/platform/windows/windows_window_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'notification forwarder sends one validated encoded activation', + () async { + const channel = MethodChannel(windowsDesktopChannelName); + MethodCall? received; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + received = call; + return true; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + const activation = DesktopActivation( + kind: DesktopActivationKind.notification, + action: 'snooze', + payload: { + 'notificationScheduleId': 'schedule-42', + 'itemKind': 'event', + 'accountId': 'account-7', + 'sourceId': 'calendar-3', + 'itemId': 'event-9', + }, + ); + + expect(await forwardWindowsActivationToPrimary(activation), isTrue); + expect(received?.method, 'forwardActivation'); + expect( + DesktopActivation.tryDecode(received?.arguments as String)?.toJson(), + activation.toJson(), + ); + }, + ); + + test( + 'notification forwarder rejects invalid activation before IPC', + () async { + const channel = MethodChannel(windowsDesktopChannelName); + var calls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls += 1; + return true; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + const activation = DesktopActivation( + kind: DesktopActivationKind.notification, + action: 'open', + payload: { + 'notificationScheduleId': 'schedule-42', + 'command': 'not permitted', + }, + ); + + expect(await forwardWindowsActivationToPrimary(activation), isFalse); + expect(calls, 0); + }, + ); +} diff --git a/test/platform/windows/windows_assets_test.dart b/test/platform/windows/windows_assets_test.dart new file mode 100644 index 00000000..dbad57dd --- /dev/null +++ b/test/platform/windows/windows_assets_test.dart @@ -0,0 +1,69 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as image; + +void main() { + test('Windows icons contain all committed resolutions', () { + for (final path in const [ + 'windows/runner/resources/app_icon.ico', + 'assets/windows/busymax_tray.ico', + 'assets/windows/busymax_tray_offline.ico', + ]) { + final decoder = image.IcoDecoder(); + final info = decoder.startDecode(File(path).readAsBytesSync()); + expect(info, isNotNull, reason: path); + expect(info!.numFrames, 9, reason: path); + expect( + { + for (var index = 0; index < info.numFrames; index++) + decoder.decodeFrame(index)!.width, + }, + {16, 20, 24, 32, 40, 48, 64, 128, 256}, + reason: path, + ); + } + }); + + test( + 'MSIX visual assets have every declared scale and transparent padding', + () { + const logicalSizes = { + 'StoreLogo': 50, + 'Square44x44Logo': 44, + 'Square150x150Logo': 150, + 'FileAssociation': 44, + }; + const scales = [100, 125, 150, 200, 400]; + for (final MapEntry(key: name, value: logicalSize) + in logicalSizes.entries) { + for (final scale in scales) { + final path = 'windows/runner/resources/msix/$name.scale-$scale.png'; + final asset = image.decodePng(File(path).readAsBytesSync()); + expect(asset, isNotNull, reason: path); + final expectedSize = (logicalSize * scale / 100).round(); + expect(asset!.width, expectedSize, reason: path); + expect(asset.height, expectedSize, reason: path); + expect(asset.getPixel(0, 0).a, 0, reason: '$path needs safe padding'); + } + } + }, + ); + + test('MSIX manifest assets also have real unqualified files', () { + const logicalSizes = { + 'StoreLogo.png': 50, + 'Square44x44Logo.png': 44, + 'Square150x150Logo.png': 150, + 'FileAssociationLogo.png': 44, + }; + for (final MapEntry(key: name, value: size) in logicalSizes.entries) { + final path = 'windows/runner/resources/msix/$name'; + final asset = image.decodePng(File(path).readAsBytesSync()); + expect(asset, isNotNull, reason: path); + expect(asset!.width, size, reason: path); + expect(asset.height, size, reason: path); + expect(asset.getPixel(0, 0).a, 0, reason: '$path needs safe padding'); + } + }); +} diff --git a/test/platform/windows/windows_event_reminders_test.dart b/test/platform/windows/windows_event_reminders_test.dart new file mode 100644 index 00000000..01150bf9 --- /dev/null +++ b/test/platform/windows/windows_event_reminders_test.dart @@ -0,0 +1,55 @@ +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/ui/windows/windows_event_reminders.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'decodes multiple provider reminders without losing start-time zero', + () { + expect( + decodeWindowsEventReminderMinutes({ + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 30}, + {'method': 'popup', 'minutes': 0}, + {'method': 'popup', 'minutes': 30}, + ], + }), + [30, 0], + ); + expect( + decodeWindowsEventReminderMinutes({ + 'isReminderOn': true, + 'reminderMinutesBeforeStart': 15, + }), + [15], + ); + }, + ); + + test('encodes all Google reminders and one Microsoft reminder', () { + expect( + encodeWindowsEventReminderPayload(BusyProvider.google, const [30, 5, 30]), + { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 30}, + {'method': 'popup', 'minutes': 5}, + ], + }, + ); + expect( + encodeWindowsEventReminderPayload(BusyProvider.microsoft, const [30, 5]), + {'isReminderOn': true, 'reminderMinutesBeforeStart': 30}, + ); + expect( + encodeWindowsEventReminderPayload(BusyProvider.microsoft, const []), + {'isReminderOn': false}, + ); + }); + + test('keeps custom values selectable and chooses an unused default', () { + expect(windowsEventReminderValuesFor(15), contains(15)); + expect(nextWindowsEventReminderMinute(const [0, 5, 10]), 30); + }); +} diff --git a/test/platform/windows/windows_guest_update_dialog_test.dart b/test/platform/windows/windows_guest_update_dialog_test.dart new file mode 100644 index 00000000..3626d55d --- /dev/null +++ b/test/platform/windows/windows_guest_update_dialog_test.dart @@ -0,0 +1,79 @@ +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/ui/windows/windows_guest_update_dialog.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Google guest update dialog returns do-not-send', (tester) async { + CalendarGuestUpdatePolicy? result; + await tester.pumpWidget( + _Harness( + onOpen: (context) async { + result = await showWindowsGuestUpdateDialog( + context, + provider: BusyProvider.google, + action: WindowsGuestUpdateAction.save, + ); + }, + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(find.text('Notify guests?'), findsOneWidget); + + await tester.tap(find.text('Don’t send')); + await tester.pumpAndSettle(); + expect(result, CalendarGuestUpdatePolicy.doNotSend); + }); + + testWidgets('Microsoft guest deletion cannot suppress cancellation', ( + tester, + ) async { + CalendarGuestUpdatePolicy? result; + await tester.pumpWidget( + _Harness( + onOpen: (context) async { + result = await showWindowsGuestUpdateDialog( + context, + provider: BusyProvider.microsoft, + action: WindowsGuestUpdateAction.delete, + ); + }, + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(find.text('Delete meeting?'), findsOneWidget); + expect(find.text('Don’t send'), findsNothing); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + expect(result, CalendarGuestUpdatePolicy.send); + }); +} + +class _Harness extends StatelessWidget { + const _Harness({required this.onOpen}); + + final Future Function(BuildContext context) onOpen; + + @override + Widget build(BuildContext context) { + return FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Center( + child: Button( + onPressed: () => onOpen(context), + child: const Text('Open'), + ), + ), + ), + ); + } +} diff --git a/test/platform/windows/windows_notification_backend_test.dart b/test/platform/windows/windows_notification_backend_test.dart new file mode 100644 index 00000000..a8f11ad1 --- /dev/null +++ b/test/platform/windows/windows_notification_backend_test.dart @@ -0,0 +1,259 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:busymax/src/features/notifications/desktop_notification_backend.dart'; +import 'package:busymax/src/platform/common/desktop_services.dart'; +import 'package:busymax/src/platform/windows/windows_notification_backend.dart'; +import 'package:busymax/src/platform/windows/windows_notification_id_store.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory directory; + + setUp(() async { + directory = await Directory.systemTemp.createTemp( + 'busymax-windows-notifications-', + ); + }); + + tearDown(() async { + if (await directory.exists()) await directory.delete(recursive: true); + }); + + WindowsNotificationIdStore ids() => + WindowsNotificationIdStore(File('${directory.path}/ids.json')); + + test('missing AUMID fails initialization', () async { + await expectLater( + WindowsNotificationBackend.create( + appUserModelId: '', + onActivation: (_) {}, + plugin: _FakeNotificationsPlugin(), + idStore: ids(), + ), + throwsArgumentError, + ); + }); + + test('native initialization false is a typed visible failure', () async { + await expectLater( + WindowsNotificationBackend.create( + appUserModelId: 'BusyStack.BusyMax_test', + onActivation: (_) {}, + plugin: _FakeNotificationsPlugin(initializeResult: false), + idStore: ids(), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'toast-registration-failed', + ), + ), + ); + }); + + test('initialization exception is converted to a sanitized code', () async { + await expectLater( + WindowsNotificationBackend.create( + appUserModelId: 'BusyStack.BusyMax_test', + onActivation: (_) {}, + plugin: _FakeNotificationsPlugin( + initializeError: StateError('private native detail'), + ), + idStore: ids(), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'initialization-failed', + ), + ), + ); + }); + + test( + 'installed package identity is queried through the runner bridge', + () async { + const channel = MethodChannel('busymax/test/package-identity'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'hasPackageIdentity'); + return true; + }); + expect( + await WindowsNotificationBackend.hasPackageIdentity(channel: channel), + isTrue, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + expect( + await WindowsNotificationBackend.hasPackageIdentity(channel: channel), + isFalse, + ); + }, + ); + + test('activation forwarder opens no application storage', () async { + final corruptStore = ids(); + await corruptStore.file.writeAsString('{not-json'); + final backend = await WindowsNotificationBackend.create( + appUserModelId: 'BusyStack.BusyMax_test', + onActivation: (_) {}, + plugin: _FakeNotificationsPlugin(), + idStore: corruptStore, + activationForwarderOnly: true, + ); + await expectLater( + backend.notify(_request('must-not-write')), + throwsStateError, + ); + expect(await corruptStore.file.readAsString(), '{not-json'); + }); + + test('display and cancellation use the same stable persisted ID', () async { + final plugin = _FakeNotificationsPlugin(); + final backend = await WindowsNotificationBackend.create( + appUserModelId: 'BusyStack.BusyMax_test', + onActivation: (_) {}, + plugin: plugin, + idStore: ids(), + ); + await backend.notify(_request('schedule-1')); + await backend.cancel('schedule-1'); + expect(plugin.shownIds, hasLength(1)); + expect(plugin.cancelledIds, [plugin.shownIds.single]); + + final restartedPlugin = _FakeNotificationsPlugin(); + final restarted = await WindowsNotificationBackend.create( + appUserModelId: 'BusyStack.BusyMax_test', + onActivation: (_) {}, + plugin: restartedPlugin, + idStore: ids(), + ); + await restarted.notify(_request('schedule-1')); + expect(restartedPlugin.shownIds.single, plugin.shownIds.single); + }); + + test('body activation uses validated common activation model', () async { + final activations = []; + final plugin = _FakeNotificationsPlugin(); + final backend = await WindowsNotificationBackend.create( + appUserModelId: 'BusyStack.BusyMax_test', + onActivation: activations.add, + plugin: plugin, + idStore: ids(), + ); + await backend.notify(_request('schedule-body')); + plugin.response!( + NotificationResponse( + notificationResponseType: NotificationResponseType.selectedNotification, + payload: plugin.payloads.single, + ), + ); + expect(activations, hasLength(1)); + expect(activations.single.kind, DesktopActivationKind.notification); + expect(activations.single.action, 'default'); + expect( + activations.single.payload?['notificationScheduleId'], + 'schedule-body', + ); + }); + + test('Open, Snooze, and Dismiss route through one validated path', () async { + final activations = []; + final plugin = _FakeNotificationsPlugin(); + final backend = await WindowsNotificationBackend.create( + appUserModelId: 'BusyStack.BusyMax_test', + onActivation: activations.add, + plugin: plugin, + idStore: ids(), + ); + await backend.notify(_request('schedule-actions')); + final actions = plugin.details.single.windows!.actions; + for (final expected in ['open', 'snooze', 'dismiss']) { + final action = actions.singleWhere( + (candidate) => jsonDecode(candidate.arguments)['action'] == expected, + ); + plugin.response!( + NotificationResponse( + notificationResponseType: + NotificationResponseType.selectedNotificationAction, + payload: action.arguments, + ), + ); + } + expect(activations.map((activation) => activation.action), [ + 'open', + 'snooze', + 'dismiss', + ]); + }); +} + +BusyMaxNotificationRequest _request(String id) => BusyMaxNotificationRequest( + stableId: id, + title: 'Reminder', + payload: {'notificationScheduleId': id, 'itemKind': 'event'}, + actions: const [ + BusyMaxNotificationAction('open', 'Open'), + BusyMaxNotificationAction('snooze', 'Snooze'), + BusyMaxNotificationAction('dismiss', 'Dismiss'), + ], +); + +final class _FakeNotificationsPlugin + implements WindowsNotificationPluginAdapter { + _FakeNotificationsPlugin({ + this.initializeResult = true, + this.initializeError, + }); + + final bool initializeResult; + final Object? initializeError; + DidReceiveNotificationResponseCallback? response; + final shownIds = []; + final cancelledIds = []; + final payloads = []; + final details = []; + + @override + Future initialize({ + required InitializationSettings settings, + DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse, + }) async { + if (initializeError case final error?) throw error; + response = onDidReceiveNotificationResponse; + return initializeResult; + } + + @override + Future + getNotificationAppLaunchDetails() async => + const NotificationAppLaunchDetails(false); + + @override + Future show({ + required int id, + String? title, + String? body, + NotificationDetails? notificationDetails, + String? payload, + }) async { + shownIds.add(id); + payloads.add(payload); + details.add(notificationDetails!); + } + + @override + Future cancel({required int id}) async { + cancelledIds.add(id); + } + + @override + Future dispose() async {} +} diff --git a/test/platform/windows/windows_notification_id_store_test.dart b/test/platform/windows/windows_notification_id_store_test.dart new file mode 100644 index 00000000..e8732566 --- /dev/null +++ b/test/platform/windows/windows_notification_id_store_test.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:busymax/src/platform/windows/windows_notification_id_store.dart'; + +void main() { + late Directory directory; + late File file; + + setUp(() async { + directory = await Directory.systemTemp.createTemp( + 'busymax-notification-ids-', + ); + file = File('${directory.path}/ids.json'); + }); + + tearDown(() async { + if (await directory.exists()) await directory.delete(recursive: true); + }); + + test('assigns a unique ID to every schedule identifier', () async { + final store = WindowsNotificationIdStore(file); + final ids = await Future.wait([ + store.idFor('schedule-a'), + store.idFor('schedule-b'), + store.idFor('schedule-c'), + ]); + expect(ids.toSet(), hasLength(3)); + expect(ids, everyElement(inInclusiveRange(1, 0x7fffffff))); + }); + + test('keeps IDs stable in memory and after restart', () async { + final first = WindowsNotificationIdStore(file); + final id = await first.idFor('schedule-stable'); + expect(await first.idFor('schedule-stable'), id); + + final restarted = WindowsNotificationIdStore(file); + expect(await restarted.idFor('schedule-stable'), id); + expect(await restarted.idFor('schedule-new'), isNot(id)); + }); + + test('rejects persisted collisions instead of overwriting a toast', () async { + await file.writeAsString( + jsonEncode({ + 'version': 1, + 'nextId': 2, + 'ids': {'schedule-a': 1, 'schedule-b': 1}, + }), + ); + final store = WindowsNotificationIdStore(file); + await expectLater(store.idFor('schedule-a'), throwsFormatException); + }); + + test('serializes concurrent allocation without collisions', () async { + final store = WindowsNotificationIdStore(file); + final ids = await Future.wait([ + for (var index = 0; index < 100; index++) store.idFor('schedule-$index'), + ]); + expect(ids.toSet(), hasLength(100)); + }); + + test( + 'recovers the last committed map after interrupted replacement', + () async { + final first = WindowsNotificationIdStore(file); + final id = await first.idFor('schedule-before-crash'); + final backup = File('${file.path}.bak'); + await file.rename(backup.path); + + final restarted = WindowsNotificationIdStore(file); + await restarted.initialize(); + expect(await restarted.idFor('schedule-before-crash'), id); + expect(await file.exists(), isTrue); + expect(await backup.exists(), isFalse); + }, + ); + + test('initialization rejects a corrupt persisted map', () async { + await file.writeAsString('{not-json'); + final store = WindowsNotificationIdStore(file); + await expectLater(store.initialize(), throwsFormatException); + await expectLater(store.idFor('schedule-a'), throwsFormatException); + }); +} diff --git a/test/platform/windows/windows_notification_readiness_ui_test.dart b/test/platform/windows/windows_notification_readiness_ui_test.dart new file mode 100644 index 00000000..145867f4 --- /dev/null +++ b/test/platform/windows/windows_notification_readiness_ui_test.dart @@ -0,0 +1,46 @@ +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/features/notifications/desktop_notification_backend.dart'; +import 'package:busymax/src/ui/windows/windows_settings_page.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + for (final testCase in <({DesktopNotificationReadiness readiness, String message})>[ + ( + readiness: const DesktopNotificationReadiness( + DesktopNotificationReadinessState.unavailableUnpackaged, + ), + message: + 'This unpackaged development run cannot use Windows notifications. Install the test-signed MSIX to test reminders.', + ), + ( + readiness: const DesktopNotificationReadiness( + DesktopNotificationReadinessState.failedInstalledPackage, + ), + message: + 'BusyMax could not initialize Windows notifications. Reminders will not appear until this installation problem is resolved.', + ), + ]) { + testWidgets( + 'notification failure is visible: ${testCase.readiness.state}', + (tester) async { + await tester.pumpWidget( + FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: WindowsNotificationReadinessInfoBar( + readiness: testCase.readiness, + ), + ), + ); + + expect( + find.text('Windows notifications are unavailable'), + findsOneWidget, + ); + expect(find.text(testCase.message), findsOneWidget); + expect(find.byType(InfoBar), findsOneWidget); + }, + ); + } +} diff --git a/test/platform/windows/windows_packaging_contract_test.dart b/test/platform/windows/windows_packaging_contract_test.dart new file mode 100644 index 00000000..26f14504 --- /dev/null +++ b/test/platform/windows/windows_packaging_contract_test.dart @@ -0,0 +1,137 @@ +import 'dart:io'; + +import 'package:busymax/src/platform/windows/windows_notification_backend.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xml/xml.dart'; + +void main() { + final manifestSource = File( + 'tool/windows/AppxManifest.xml.template', + ).readAsStringSync(); + final manifest = XmlDocument.parse(manifestSource); + + Iterable elements(String localName) => manifest.descendants + .whereType() + .where((element) => element.name.local == localName); + + test('Store manifest stays x64-only and targets Windows 11 24H2', () { + final identity = elements('Identity').single; + final target = elements('TargetDeviceFamily').single; + final application = elements('Application').single; + + expect(identity.getAttribute('ProcessorArchitecture'), 'x64'); + expect(target.getAttribute('Name'), 'Windows.Desktop'); + expect(target.getAttribute('MinVersion'), '10.0.26100.0'); + expect(application.getAttribute('Id'), 'BusyMax'); + expect(application.getAttribute('Executable'), 'busymax.exe'); + expect( + application.getAttribute('EntryPoint'), + 'Windows.FullTrustApplication', + ); + }); + + test('Store manifest registers only required activation surfaces', () { + final categories = elements( + 'Extension', + ).map((element) => element.getAttribute('Category')).toSet(); + expect(categories, { + 'windows.fileTypeAssociation', + 'windows.protocol', + 'windows.startupTask', + 'windows.toastNotificationActivation', + 'windows.comServer', + }); + expect(elements('FileType').map((element) => element.innerText).toList(), [ + '.ics', + ]); + expect( + elements('Protocol').map((element) => element.getAttribute('Name')), + ['webcal'], + ); + + final startup = elements('StartupTask').single; + expect(startup.getAttribute('TaskId'), 'BusyMaxStartupTask'); + expect(startup.getAttribute('Enabled'), 'false'); + expect( + startup.parentElement!.getAttribute( + 'Parameters', + namespace: + 'http://schemas.microsoft.com/appx/manifest/uap/windows10/10', + ), + '--start-minimized', + ); + + final toastClsid = elements( + 'ToastNotificationActivation', + ).single.getAttribute('ToastActivatorCLSID'); + final toast = elements('ToastNotificationActivation').single; + expect( + toast.name.namespaceUri, + 'http://schemas.microsoft.com/appx/manifest/desktop/windows10', + ); + expect( + toast.parentElement!.name.namespaceUri, + 'http://schemas.microsoft.com/appx/manifest/desktop/windows10', + ); + final ignorableNamespaces = manifest.rootElement + .getAttribute('IgnorableNamespaces')! + .split(' '); + expect(ignorableNamespaces, contains('desktop')); + expect(ignorableNamespaces, isNot(contains('desktop4'))); + expect(manifestSource, isNot(contains('xmlns:desktop4='))); + final comClsid = elements('Class').single.getAttribute('Id'); + expect(toastClsid, busyMaxToastActivatorClsid); + expect(comClsid, toastClsid); + }); + + test('every manifest logo has a matching unqualified source asset', () { + final references = { + elements('Logo').first.innerText, + elements('Logo').last.innerText, + elements('VisualElements').single.getAttribute('Square44x44Logo')!, + elements('VisualElements').single.getAttribute('Square150x150Logo')!, + }; + expect(references, { + r'Assets\StoreLogo.png', + r'Assets\FileAssociationLogo.png', + r'Assets\Square44x44Logo.png', + r'Assets\Square150x150Logo.png', + }); + for (final reference in references) { + final name = reference.split(r'\').last; + expect( + File('windows/runner/resources/msix/$name').existsSync(), + isTrue, + reason: reference, + ); + } + }); + + test('Store manifest capability set remains minimal', () { + final capabilities = elements('Capabilities').single.childElements + .map((element) => element.getAttribute('Name')) + .toSet(); + expect(capabilities, {'internetClient', 'runFullTrust'}); + }); + + test('runner preserves native DPI and acknowledged per-user IPC', () { + final executableManifest = File( + 'windows/runner/runner.exe.manifest', + ).readAsStringSync(); + final runner = File( + 'windows/runner/single_instance.cpp', + ).readAsStringSync(); + + expect(executableManifest, contains('PerMonitorV2')); + expect( + executableManifest, + contains(' const [ConnectivityResult.wifi], + connectivityChanges: const Stream.empty(), + ); + addTearDown(monitor.dispose); + const account = AccountEntity( + id: 'account-1', + provider: BusyProvider.google, + authority: 'https://accounts.google.com', + providerAccountId: 'owner@example.test', + authState: accountAuthStateSignedIn, + displayName: 'Personal', + ); + const source = CalendarSourceEntity( + id: 'calendar-1', + accountId: 'account-1', + provider: BusyProvider.google, + providerCalendarId: 'work@example.test', + summary: 'Work calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + dataOwner: 'owner@example.test', + authenticatedAccountEmail: 'owner@example.test', + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + networkConnectivityMonitorProvider.overrideWithValue(monitor), + ], + child: FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: SizedBox( + width: 360, + height: 720, + child: WindowsScheduleSourcePane( + selectedDate: DateTime(2026, 8, 31), + accounts: const [account], + calendarSources: const [source], + taskLists: const [], + visibleCalendarSourceIds: const {'calendar-1'}, + visibleTaskListKeys: const {}, + onDateSelected: (_) {}, + onCalendarVisibilityChanged: (_, _) {}, + onTaskListVisibilityChanged: (_, _) {}, + onSourcesChanged: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Work calendar'), findsOneWidget); + expect(find.byType(DropDownButton), findsNWidgets(2)); + + await tester.tap(find.byType(DropDownButton).last); + await tester.pumpAndSettle(); + + expect(find.text('Event reminders — On'), findsOneWidget); + expect(find.text('Calendar color'), findsOneWidget); + expect(find.text('Rename'), findsOneWidget); + expect(find.text('Delete'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/platform/windows/windows_task_create_fields_test.dart b/test/platform/windows/windows_task_create_fields_test.dart new file mode 100644 index 00000000..89c0ab11 --- /dev/null +++ b/test/platform/windows/windows_task_create_fields_test.dart @@ -0,0 +1,79 @@ +import 'package:busymax/src/dav/ical/ical_task_alarm.dart'; +import 'package:busymax/src/features/recurrence/domain/recurrence_rule.dart'; +import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/ui/windows/windows_task_create_fields.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('all-day DAV tasks omit timed provider fields and keep every alarm', () { + final fields = _fields( + capability: nextcloudTaskCollectionCapabilities, + provider: BusyProvider.nextcloud, + scheduledAllDay: true, + alarms: [ + IcalTaskAlarm.displayAbsolute(DateTime.utc(2026, 9, 1, 8)), + IcalTaskAlarm.displayAbsolute(DateTime.utc(2026, 9, 1, 9)), + ], + ); + + expect(fields, isNot(contains('microsoftDueDateTime'))); + expect(fields, isNot(contains('microsoftStartDateTime'))); + expect( + fields['taskAlarms'], + isA>().having((value) => value.length, 'length', 2), + ); + }); + + test('timed Microsoft tasks include zones and one provider reminder', () { + final fields = _fields( + capability: microsoftTaskCollectionCapabilities, + provider: BusyProvider.microsoft, + scheduledAllDay: false, + reminder: DateTime(2026, 9, 1, 8, 45), + ); + + expect(fields['microsoftDueTimeZone'], 'America/Vancouver'); + expect(fields['microsoftStartTimeZone'], 'America/Vancouver'); + expect(fields['microsoftIsReminderOn'], isTrue); + expect(fields, isNot(contains('taskAlarms'))); + }); + + test('an empty Microsoft reminder emits the explicit disabled state', () { + final fields = _fields( + capability: microsoftTaskCollectionCapabilities, + provider: BusyProvider.microsoft, + scheduledAllDay: true, + ); + + expect(fields['microsoftIsReminderOn'], isFalse); + }); +} + +Map _fields({ + required TaskCollectionCapabilities capability, + required BusyProvider provider, + required bool scheduledAllDay, + DateTime? reminder, + List alarms = const [], +}) => buildWindowsTaskCreateFields( + capability: capability, + provider: provider, + due: DateTime(2026, 9, 1, 10), + start: DateTime(2026, 9, 1, 9), + reminder: reminder, + alarms: alarms, + scheduledAllDay: scheduledAllDay, + timeZone: 'America/Vancouver', + recurrence: const RecurrenceRule.none(), + importance: 'normal', + status: '', + priority: 0, + progress: 0, + location: '', + taskUrl: '', + classification: 'PUBLIC', + pinned: false, + hideSubtasks: false, + hideCompletedSubtasks: false, +); diff --git a/test/platform/windows/windows_task_details_dialog_test.dart b/test/platform/windows/windows_task_details_dialog_test.dart new file mode 100644 index 00000000..9610a86a --- /dev/null +++ b/test/platform/windows/windows_task_details_dialog_test.dart @@ -0,0 +1,254 @@ +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/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_item.dart'; +import 'package:busymax/src/ui/windows/windows_task_details_dialog.dart'; +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Fluent task details exposes hierarchy and list workflows', ( + tester, + ) async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'account-1', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'owner@example.test', + credentialKind: 'oauth', + authState: const Value('signed_in'), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + for (final (id, title) in [('list-1', 'Inbox'), ('list-2', 'Work')]) { + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: 'account-1', + id: id, + title: title, + rawJson: '{}', + createdLocalAtUtc: _now, + updatedLocalAtUtc: _now, + ), + ); + } + final tasks = _TestTasksRepository(database); + final lists = TaskListsRepository( + database: database, + accountId: 'account-1', + nowUtc: () => DateTime.utc(2026, 8, 31), + ); + Object? openError; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(database), + accountsRepositoryProvider.overrideWithValue( + _TestAccountsRepository(database), + ), + localTimeZoneProvider.overrideWithValue('Etc/UTC'), + tasksRepositoryForAccountProvider.overrideWith( + (ref, accountId) => tasks, + ), + taskListsRepositoryForAccountProvider.overrideWith( + (ref, accountId) => lists, + ), + ], + child: FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: Consumer( + builder: (context, ref, _) => Button( + onPressed: () async { + try { + await showWindowsTaskDetailsDialog( + context, + ref, + const TaskScheduleItem( + id: 'parent', + accountId: 'account-1', + provider: BusyProvider.google, + sourceId: 'list-1', + title: 'Parent', + completed: false, + allDay: true, + ), + ); + } on Object catch (error) { + openError = error; + } + }, + child: const Text('Open details'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open details')); + await tester.pumpAndSettle(); + + expect(openError, isNull); + expect(find.byType(ContentDialog), findsOneWidget); + expect(find.text('Subtasks'), findsOneWidget); + expect(find.text('Existing child'), findsOneWidget); + expect(find.text('Move to top'), findsOneWidget); + expect(find.byType(ComboBox), findsOneWidget); + + await tester.enterText( + find.widgetWithText(TextBox, 'Create subtask'), + 'New child', + ); + await tester.pump(); + final createButton = find.widgetWithText(Button, 'Create subtask'); + await tester.ensureVisible(createButton); + await tester.pumpAndSettle(); + await tester.tap(createButton); + await tester.pumpAndSettle(); + + expect(find.text('New child'), findsOneWidget); + expect(tasks.createdSubtaskTitles, ['New child']); + final existingChild = find.text('Existing child'); + await tester.ensureVisible(existingChild); + await tester.tap(existingChild); + await tester.pumpAndSettle(); + expect(tasks.watchedTaskIds.last, 'child'); + + final titleField = find.byWidgetPredicate( + (widget) => + widget is TextBox && widget.controller?.text == 'Existing child', + ); + await tester.enterText(titleField, 'Edited child'); + await tester.pump(); + final parentRow = find.byWidgetPredicate( + (widget) => + widget is ListTile && + widget.title is Text && + (widget.title! as Text).data == 'Parent', + ); + await tester.tap(parentRow); + await tester.pumpAndSettle(); + expect(find.text('Discard changes?'), findsOneWidget); + expect(tasks.watchedTaskIds.last, 'child'); + + await tester.tap(find.widgetWithText(Button, 'Cancel').last); + await tester.pumpAndSettle(); + expect(find.text('Discard changes?'), findsNothing); + await tester.tap(parentRow); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Discard').last); + await tester.pumpAndSettle(); + expect(tasks.watchedTaskIds.last, 'parent'); + expect(tester.takeException(), isNull); + }); +} + +class _TestAccountsRepository extends AccountsRepository { + _TestAccountsRepository(AppDatabase database) : super(database: database); + + @override + Stream> watchAccounts() => Stream.value(const [ + AccountEntity( + id: 'account-1', + provider: BusyProvider.google, + authority: 'https://accounts.google.com', + providerAccountId: 'owner@example.test', + authState: accountAuthStateSignedIn, + ), + ]); +} + +class _TestTasksRepository extends TasksRepository { + _TestTasksRepository(AppDatabase database) + : super(database: database, accountId: 'account-1'); + + final createdSubtaskTitles = []; + final watchedTaskIds = []; + final _parent = const TaskEntity( + accountId: 'account-1', + taskListId: 'list-1', + id: 'parent', + title: 'Parent', + localDirty: false, + pendingDelete: false, + pendingMove: false, + rawJson: '{}', + updatedLocalAtUtc: _now, + ); + final _children = [ + TaskSubtaskEntity.task( + const TaskEntity( + accountId: 'account-1', + taskListId: 'list-1', + id: 'child', + title: 'Existing child', + localDirty: false, + pendingDelete: false, + pendingMove: false, + rawJson: '{}', + updatedLocalAtUtc: _now, + ), + hasChildren: false, + ), + ]; + + @override + Stream watchTask(String taskListId, String taskId) { + watchedTaskIds.add(taskId); + if (taskId == 'parent') return Stream.value(_parent); + return Stream.value( + _children.where((subtask) => subtask.id == taskId).firstOrNull?.task, + ); + } + + @override + Stream watchTaskHierarchy( + String taskListId, + String taskId, + ) => Stream.value( + taskId == 'parent' + ? TaskHierarchySnapshot(parent: null, subtasks: List.of(_children)) + : TaskHierarchySnapshot(parent: _parent, subtasks: const []), + ); + + @override + Future createSubtask({ + required String taskListId, + required String parentTaskId, + required String title, + }) async { + createdSubtaskTitles.add(title); + _children.add( + TaskSubtaskEntity.task( + TaskEntity( + accountId: 'account-1', + taskListId: taskListId, + id: 'new-child', + title: title, + localDirty: true, + pendingDelete: false, + pendingMove: false, + rawJson: '{}', + updatedLocalAtUtc: _now, + ), + hasChildren: false, + ), + ); + } +} + +const _now = '2026-08-31T00:00:00.000Z'; diff --git a/test/platform/windows/windows_time_zone_source_test.dart b/test/platform/windows/windows_time_zone_source_test.dart new file mode 100644 index 00000000..02f058a8 --- /dev/null +++ b/test/platform/windows/windows_time_zone_source_test.dart @@ -0,0 +1,96 @@ +import 'package:busymax/src/platform/windows/windows_time_zone_source.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_timezone/flutter_timezone.dart'; +import 'package:timezone/data/latest_all.dart' as time_zone_data; +import 'package:timezone/timezone.dart' as time_zone; + +void main() { + setUpAll(time_zone_data.initializeTimeZones); + + test('accepts IANA and normalizes UTC', () async { + final iana = WindowsLocalTimeZoneSource( + load: () async => TimezoneInfo(identifier: 'Asia/Kathmandu'), + ); + final utc = WindowsLocalTimeZoneSource( + load: () async => TimezoneInfo(identifier: 'UTC'), + ); + + expect(await iana.currentIanaTimeZone(), 'Asia/Kathmandu'); + expect(await utc.currentIanaTimeZone(), 'Etc/UTC'); + }); + + test('maps Windows identifiers and reports unknown values', () async { + final mapped = WindowsLocalTimeZoneSource( + load: () async => TimezoneInfo(identifier: 'India Standard Time'), + ); + final unknown = WindowsLocalTimeZoneSource( + load: () async => TimezoneInfo(identifier: 'Etc/Unknown'), + ); + + expect(await mapped.currentIanaTimeZone(), 'Asia/Kolkata'); + expect(await unknown.currentIanaTimeZone(), 'Etc/UTC'); + expect(unknown.lastUnrecognizedIdentifier, 'Etc/Unknown'); + expect( + unknown.diagnostic, + 'local-time-zone/unrecognized=Etc/Unknown; fallback=Etc/UTC', + ); + }); + + test('exposes only allowlisted native failure diagnostics', () async { + final mappedFailure = WindowsLocalTimeZoneSource( + load: () async => throw PlatformException( + code: 'icu-timezone-mapping-failed', + message: 'sensitive native detail', + ), + ); + final unknownFailure = WindowsLocalTimeZoneSource( + load: () async => throw PlatformException( + code: 'native-secret-C:/Users/albert', + message: 'sensitive native detail', + ), + ); + + expect(await mappedFailure.currentIanaTimeZone(), 'Etc/UTC'); + expect( + mappedFailure.diagnostic, + 'local-time-zone/icu-timezone-mapping-failed; fallback=Etc/UTC', + ); + expect(await unknownFailure.currentIanaTimeZone(), 'Etc/UTC'); + expect( + unknownFailure.diagnostic, + 'local-time-zone/unavailable; fallback=Etc/UTC', + ); + }); + + test('mapped zones retain DST, skipped, ambiguous, and offset behavior', () { + final pacific = time_zone.getLocation( + windowsToIanaTimeZones['Pacific Standard Time']!, + ); + final newfoundland = time_zone.getLocation( + windowsToIanaTimeZones['Newfoundland Standard Time']!, + ); + final kathmandu = time_zone.getLocation( + windowsToIanaTimeZones['Nepal Standard Time']!, + ); + + final winter = time_zone.TZDateTime(pacific, 2026, 1, 15, 12); + final summer = time_zone.TZDateTime(pacific, 2026, 7, 15, 12); + expect(winter.timeZoneOffset, const Duration(hours: -8)); + expect(summer.timeZoneOffset, const Duration(hours: -7)); + + final skipped = time_zone.TZDateTime(pacific, 2026, 3, 8, 2, 30); + expect(skipped.hour, 3); + final ambiguous = time_zone.TZDateTime(pacific, 2026, 11, 1, 1, 30); + expect(ambiguous.hour, 1); + + expect( + time_zone.TZDateTime(newfoundland, 2026, 1, 15).timeZoneOffset, + const Duration(hours: -3, minutes: -30), + ); + expect( + time_zone.TZDateTime(kathmandu, 2026, 1, 15).timeZoneOffset, + const Duration(hours: 5, minutes: 45), + ); + }); +} diff --git a/test/platform/windows/windows_tray_close_policy_test.dart b/test/platform/windows/windows_tray_close_policy_test.dart new file mode 100644 index 00000000..ee247074 --- /dev/null +++ b/test/platform/windows/windows_tray_close_policy_test.dart @@ -0,0 +1,28 @@ +import 'package:busymax/src/ui/windows/windows_desktop_runtime.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('close can hide only when background mode and a usable tray exist', () { + expect( + shouldWindowsHideOnClose( + runInBackgroundWhenClosed: true, + trayAvailable: true, + ), + isTrue, + ); + expect( + shouldWindowsHideOnClose( + runInBackgroundWhenClosed: true, + trayAvailable: false, + ), + isFalse, + ); + expect( + shouldWindowsHideOnClose( + runInBackgroundWhenClosed: false, + trayAvailable: true, + ), + isFalse, + ); + }); +} diff --git a/test/providers/busy_provider_test.dart b/test/providers/busy_provider_test.dart index 29773450..5af56101 100644 --- a/test/providers/busy_provider_test.dart +++ b/test/providers/busy_provider_test.dart @@ -6,18 +6,20 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('provider identity has exactly four stable storage values', () { + test('provider identity has stable storage values', () { expect(BusyProvider.values, [ BusyProvider.google, BusyProvider.microsoft, BusyProvider.appleICloud, BusyProvider.nextcloud, + BusyProvider.webCal, ]); expect(BusyProvider.values.map((provider) => provider.storageValue), [ 'google', 'microsoft', 'apple_icloud', 'nextcloud', + 'webcal', ]); }); diff --git a/test/tools/build_install_snap_local_test.dart b/test/tool/build_install_snap_local_test.dart similarity index 93% rename from test/tools/build_install_snap_local_test.dart rename to test/tool/build_install_snap_local_test.dart index 43e777cb..f36bb8c9 100644 --- a/test/tools/build_install_snap_local_test.dart +++ b/test/tool/build_install_snap_local_test.dart @@ -4,6 +4,34 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('build_install_snap_local.sh', () { + test('explains missing OAuth configuration for local builds', () async { + final fixture = await _LocalSnapFixture.create(); + addTearDown(fixture.dispose); + + final result = await fixture.run( + arguments: ['--root', fixture.validRoot.path], + ); + final output = '${result.stdout}\n${result.stderr}'; + + expect( + result.exitCode, + 0, + reason: _processFailure(result, fixture.commandLogContents), + ); + expect( + output, + contains('Google and Microsoft sign-in will be unavailable'), + ); + expect( + output, + contains( + './tool/build_install_snap_local.sh ' + '--dart-define-from-file .snap-local/busymax-dart-defines.json', + ), + ); + expect(output, contains('Apple and Nextcloud remain available')); + }); + test('installs without removing the existing snap or its data', () async { final fixture = await _LocalSnapFixture.create(); addTearDown(fixture.dispose); @@ -183,7 +211,7 @@ void main() { await _expectUnsafeRootRejected( fixture, - arguments: const ['--root', 'tools/..'], + arguments: const ['--root', 'tool/..'], ); }); @@ -249,7 +277,7 @@ void main() { expect(fixture.commandLogContents, isNot(contains('sudo\tsnap install'))); expect(victim.readAsStringSync(), 'do not delete'); }); - }); + }, skip: !Platform.isLinux); } Future _expectUnsafeRootRejected( @@ -327,8 +355,8 @@ final class _LocalSnapFixture { final commandLog = File('${root.path}/commands.log'); _writeFile( - '${project.path}/tools/build_install_snap_local.sh', - File('tools/build_install_snap_local.sh').readAsStringSync(), + '${project.path}/tool/build_install_snap_local.sh', + File('tool/build_install_snap_local.sh').readAsStringSync(), ); _writeFile( '${project.path}/pubspec.yaml', @@ -461,7 +489,7 @@ fi return Process.run( 'bash', [ - '${project.path}/tools/build_install_snap_local.sh', + '${project.path}/tool/build_install_snap_local.sh', '--skip-tests', '--no-run', '--scaffold', diff --git a/test/tools/flutter_linux_workflow_test.dart b/test/tool/flutter_linux_workflow_test.dart similarity index 100% rename from test/tools/flutter_linux_workflow_test.dart rename to test/tool/flutter_linux_workflow_test.dart diff --git a/test/tools/install_linux_dev_desktop_test.dart b/test/tool/install_linux_dev_desktop_test.dart similarity index 95% rename from test/tools/install_linux_dev_desktop_test.dart rename to test/tool/install_linux_dev_desktop_test.dart index 2782dcf5..c0690b97 100644 --- a/test/tools/install_linux_dev_desktop_test.dart +++ b/test/tool/install_linux_dev_desktop_test.dart @@ -22,7 +22,7 @@ void main() { for (var attempt = 0; attempt < 2; attempt++) { final result = await Process.run('bash', [ - 'tools/install_linux_dev_desktop.sh', + 'tool/install_linux_dev_desktop.sh', '--executable', executable, ], environment: environment); @@ -53,7 +53,7 @@ void main() { ); final uninstall = await Process.run('bash', [ - 'tools/install_linux_dev_desktop.sh', + 'tool/install_linux_dev_desktop.sh', '--uninstall', ], environment: environment); expect(uninstall.exitCode, 0, reason: _processFailure(uninstall)); @@ -77,7 +77,7 @@ void main() { final result = await Process.run( 'bash', - ['tools/install_linux_dev_desktop.sh'], + ['tool/install_linux_dev_desktop.sh'], environment: { ...Platform.environment, 'XDG_DATA_HOME': dataHome.path, diff --git a/test/webcal/webcal_http_client_test.dart b/test/webcal/webcal_http_client_test.dart new file mode 100644 index 00000000..4980cb93 --- /dev/null +++ b/test/webcal/webcal_http_client_test.dart @@ -0,0 +1,377 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:busymax/src/webcal/webcal_http_client.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('binds validators only to the exact matching redirect hop', () async { + final first = _FakeResponse(302, headers: {'location': '/final.ics'}); + final second = _FakeResponse( + 200, + chunks: const [ + [1, 2, 3], + ], + ); + final client = _FakeHttpClient([first, second]); + final transport = IoWebCalHttpTransport(clientFactory: () => client); + final start = Uri.parse('https://calendar.example.test/start'); + final target = Uri.parse('https://calendar.example.test/final.ics'); + + final result = await transport.get( + start, + validators: const WebCalHttpValidators( + etag: '"etag"', + lastModified: 'Sat, 29 Aug 2026 20:00:00 GMT', + ), + validatorTarget: target, + ); + + expect( + result.finalUri.toString(), + 'https://calendar.example.test/final.ics', + ); + expect(result.body, [1, 2, 3]); + expect(client.requests, hasLength(2)); + final firstHeaders = client.requests.first.recordedHeaders; + expect(firstHeaders['accept'], 'text/calendar'); + expect(firstHeaders, isNot(contains('if-none-match'))); + expect(firstHeaders, isNot(contains('if-modified-since'))); + expect(firstHeaders, isNot(contains('authorization'))); + expect(firstHeaders, isNot(contains('cookie'))); + expect(firstHeaders, isNot(contains('referer'))); + expect(firstHeaders, isNot(contains('origin'))); + final redirectedHeaders = client.requests.last.recordedHeaders; + expect(redirectedHeaders['if-none-match'], '"etag"'); + expect( + redirectedHeaders['if-modified-since'], + 'Sat, 29 Aug 2026 20:00:00 GMT', + ); + }); + + test( + 'rejects redirect downgrade, user information, fragments, and loops', + () async { + for (final target in [ + 'http://calendar.example.test/feed', + 'https://user:secret@calendar.example.test/feed', + 'https://calendar.example.test/feed#fragment', + ]) { + final client = _FakeHttpClient([ + _FakeResponse(302, headers: {'location': target}), + ]); + await expectLater( + IoWebCalHttpTransport( + clientFactory: () => client, + ).get(Uri.parse('https://calendar.example.test/start')), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalRedirectTargetNotAllowed', + ), + ), + ); + expect(client.requests, hasLength(1)); + } + + final loop = _FakeHttpClient([ + _FakeResponse(302, headers: {'location': '/start'}), + ]); + await expectLater( + IoWebCalHttpTransport( + clientFactory: () => loop, + ).get(Uri.parse('https://calendar.example.test/start')), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalRedirectLoop', + ), + ), + ); + }, + ); + + test('enforces decoded body and redirect limits', () async { + final oversized = _FakeHttpClient([ + _FakeResponse( + 200, + chunks: const [ + [1, 2, 3, 4, 5], + ], + ), + ]); + await expectLater( + IoWebCalHttpTransport( + clientFactory: () => oversized, + limits: const WebCalHttpLimits(maximumDecodedBodyBytes: 4), + ).get(Uri.parse('https://calendar.example.test/start')), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalBodyTooLarge', + ), + ), + ); + + final redirects = _FakeHttpClient([ + _FakeResponse(302, headers: {'location': '/two'}), + _FakeResponse(302, headers: {'location': '/three'}), + ]); + await expectLater( + IoWebCalHttpTransport( + clientFactory: () => redirects, + limits: const WebCalHttpLimits(maximumRedirects: 1), + ).get(Uri.parse('https://calendar.example.test/one')), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalRedirectLimitExceeded', + ), + ), + ); + }); + + test('enforces stream inactivity and total deadlines', () async { + final idleController = StreamController>(); + addTearDown(idleController.close); + final idle = _FakeHttpClient([ + _FakeResponse(200, stream: idleController.stream), + ]); + await expectLater( + IoWebCalHttpTransport( + clientFactory: () => idle, + limits: const WebCalHttpLimits( + streamInactivityTimeout: Duration(milliseconds: 5), + totalDeadline: Duration(seconds: 1), + ), + ).get(Uri.parse('https://calendar.example.test/start')), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalStreamInactivityTimeout', + ), + ), + ); + + final total = _FakeHttpClient(const [], neverConnect: true); + await expectLater( + IoWebCalHttpTransport( + clientFactory: () => total, + limits: const WebCalHttpLimits( + totalDeadline: Duration(milliseconds: 5), + ), + ).get(Uri.parse('https://calendar.example.test/start')), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalTotalTimeout', + ), + ), + ); + }); + + test('marks a response conditional only when validators were sent', () async { + final target = Uri.parse('https://calendar.example.test/feed'); + final conditional = _FakeHttpClient([_FakeResponse(304)]); + final response = + await IoWebCalHttpTransport(clientFactory: () => conditional).get( + target, + validators: const WebCalHttpValidators(etag: '"one"'), + validatorTarget: target, + ); + expect(response.conditionalRequestSent, isTrue); + + final unbound = _FakeHttpClient([_FakeResponse(304)]); + final unboundResponse = + await IoWebCalHttpTransport(clientFactory: () => unbound).get( + target, + validators: const WebCalHttpValidators(etag: '"one"'), + validatorTarget: Uri.parse('https://other.example.test/feed'), + ); + expect(unboundResponse.conditionalRequestSent, isFalse); + }); + + test('does not wait for stalled redirect or 304 response bodies', () async { + final redirectBody = StreamController>(); + final notModifiedBody = StreamController>(); + final redirectClient = _FakeHttpClient([ + _FakeResponse( + 302, + headers: {'location': '/final.ics'}, + stream: redirectBody.stream, + ), + _FakeResponse( + 200, + chunks: const [ + [1], + ], + ), + ]); + + final redirected = + await IoWebCalHttpTransport(clientFactory: () => redirectClient) + .get(Uri.parse('https://calendar.example.test/start')) + .timeout(const Duration(milliseconds: 100)); + expect(redirected.body, [1]); + expect(redirectBody.hasListener, isFalse); + + final notModifiedClient = _FakeHttpClient([ + _FakeResponse(304, stream: notModifiedBody.stream), + ]); + final target = Uri.parse('https://calendar.example.test/feed'); + final notModified = + await IoWebCalHttpTransport(clientFactory: () => notModifiedClient) + .get( + target, + validators: const WebCalHttpValidators(etag: '"one"'), + validatorTarget: target, + ) + .timeout(const Duration(milliseconds: 100)); + expect(notModified.statusCode, 304); + expect(notModifiedBody.hasListener, isFalse); + }); + + test('body waits never exceed the remaining total deadline', () async { + final controller = StreamController>(); + scheduleMicrotask(() => controller.add([1])); + final client = _FakeHttpClient([ + _FakeResponse(200, stream: controller.stream), + ]); + + await expectLater( + IoWebCalHttpTransport( + clientFactory: () => client, + limits: const WebCalHttpLimits( + streamInactivityTimeout: Duration(seconds: 1), + totalDeadline: Duration(milliseconds: 20), + ), + ).get(Uri.parse('https://calendar.example.test/start')), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalTotalTimeout', + ), + ), + ); + }); +} + +final class _FakeHttpClient implements HttpClient { + _FakeHttpClient(this.responses, {this.neverConnect = false}); + + final List<_FakeResponse> responses; + final bool neverConnect; + final requests = <_FakeRequest>[]; + + @override + Future getUrl(Uri url) { + if (neverConnect) return Completer().future; + final request = _FakeRequest(url, responses[requests.length]); + requests.add(request); + return Future.value(request); + } + + @override + set connectionTimeout(Duration? value) {} + + @override + set autoUncompress(bool value) {} + + @override + void close({bool force = false}) {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _FakeRequest implements HttpClientRequest { + _FakeRequest(this.uri, this.response); + + @override + final Uri uri; + final _FakeResponse response; + final _FakeHeaders _headers = _FakeHeaders(); + + @override + HttpHeaders get headers => _headers; + + Map get recordedHeaders => _headers.values; + + @override + set followRedirects(bool value) {} + + @override + set maxRedirects(int value) {} + + @override + Future close() async => response; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _FakeResponse extends Stream> + implements HttpClientResponse { + _FakeResponse( + this.statusCode, { + Map headers = const {}, + List> chunks = const [], + Stream>? stream, + }) : _headers = _FakeHeaders(headers), + _stream = stream ?? Stream>.fromIterable(chunks); + + @override + final int statusCode; + final _FakeHeaders _headers; + final Stream> _stream; + + @override + HttpHeaders get headers => _headers; + + @override + StreamSubscription> listen( + void Function(List)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => _stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _FakeHeaders implements HttpHeaders { + _FakeHeaders([Map initial = const {}]) + : values = { + for (final entry in initial.entries) + entry.key.toLowerCase(): entry.value, + }; + + final Map values; + + @override + void set(String name, Object value, {bool preserveHeaderCase = false}) { + values[name.toLowerCase()] = value.toString(); + } + + @override + void removeAll(String name) => values.remove(name.toLowerCase()); + + @override + String? value(String name) => values[name.toLowerCase()]; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/webcal/webcal_read_only_test.dart b/test/webcal/webcal_read_only_test.dart new file mode 100644 index 00000000..57139bcb --- /dev/null +++ b/test/webcal/webcal_read_only_test.dart @@ -0,0 +1,191 @@ +import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late AppDatabase database; + late CalendarRepository repository; + + setUp(() async { + database = AppDatabase(NativeDatabase.memory()); + repository = CalendarRepository(database: database); + await _seed(database); + }); + + tearDown(() async => database.close()); + + test('rejects create, edit, delete, move, and RSVP for WebCal', () async { + final createDraft = EventEditorDraft.newEvent( + accountId: 'webcal-account-sub', + sourceId: 'webcal-calendar-sub', + providerCalendarId: 'sub', + start: DateTime(2026, 8, 30, 9), + end: DateTime(2026, 8, 30, 10), + ).copyWith(title: 'Not allowed'); + await expectLater( + repository.createLocalEvent(createDraft), + throwsA(isA()), + ); + + final webCalDetail = (await repository.loadEventDetail('webcal-event'))!; + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(webCalDetail).copyWith(title: 'Edit'), + ), + throwsA(isA()), + ); + await expectLater( + repository.deleteLocalEvent('webcal-event'), + throwsA(isA()), + ); + await expectLater( + repository.respondToLocalEvent( + 'webcal-event', + CalendarInvitationResponse.accept, + ), + throwsA(isA()), + ); + + final cloudDetail = (await repository.loadEventDetail('google-event'))!; + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(cloudDetail).copyWith( + accountId: 'webcal-account-sub', + sourceId: 'webcal-calendar-sub', + providerCalendarId: 'sub', + ), + ), + throwsA(isA()), + ); + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(webCalDetail).copyWith( + accountId: 'google-account', + sourceId: 'google-calendar', + providerCalendarId: 'primary', + ), + ), + throwsA(isA()), + ); + + expect(await database.select(database.pendingOps).get(), isEmpty); + }); +} + +Future _seed(AppDatabase database) async { + for (final account in [ + ( + id: 'webcal-account-sub', + provider: 'webcal', + authority: 'https://calendar.example.test', + providerId: 'fingerprint', + credential: 'webcal_subscription', + tasks: false, + ), + ( + id: 'google-account', + provider: 'google', + authority: 'https://accounts.google.com', + providerId: 'me@example.test', + credential: 'oauth', + tasks: true, + ), + ]) { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: account.id, + provider: account.provider, + authority: account.authority, + providerAccountId: account.providerId, + credentialKind: account.credential, + authState: const Value('signed_in'), + calendarsEnabled: const Value(true), + tasksEnabled: Value(account.tasks), + grantedScopes: const Value(''), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + } + for (final source in [ + ( + id: 'webcal-calendar-sub', + accountId: 'webcal-account-sub', + provider: 'webcal', + calendarId: 'sub', + readOnly: true, + ), + ( + id: 'google-calendar', + accountId: 'google-account', + provider: 'google', + calendarId: 'primary', + readOnly: false, + ), + ]) { + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: source.id, + accountId: source.accountId, + provider: source.provider, + providerCalendarId: source.calendarId, + summary: source.id, + readOnly: Value(source.readOnly), + accessRole: Value(source.readOnly ? 'reader' : 'owner'), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + } + for (final event in [ + ( + id: 'webcal-event', + accountId: 'webcal-account-sub', + sourceId: 'webcal-calendar-sub', + provider: 'webcal', + calendarId: 'sub', + ), + ( + id: 'google-event', + accountId: 'google-account', + sourceId: 'google-calendar', + provider: 'google', + calendarId: 'primary', + ), + ]) { + await database + .into(database.calendarEvents) + .insert( + CalendarEventsCompanion.insert( + id: event.id, + accountId: event.accountId, + calendarSourceId: event.sourceId, + provider: event.provider, + providerCalendarId: event.calendarId, + providerEventId: event.id, + title: event.id, + startDateTime: const Value('2026-08-30T09:00:00'), + startTimeZone: const Value('UTC'), + endDateTime: const Value('2026-08-30T10:00:00'), + endTimeZone: const Value('UTC'), + organizerJson: Value( + event.provider == 'google' ? '{"self":true}' : null, + ), + rawJson: const Value('{}'), + baselineRawJson: const Value('{}'), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + } +} + +const _now = '2026-08-29T00:00:00.000Z'; diff --git a/test/webcal/webcal_subscription_service_test.dart b/test/webcal/webcal_subscription_service_test.dart new file mode 100644 index 00000000..43ab4405 --- /dev/null +++ b/test/webcal/webcal_subscription_service_test.dart @@ -0,0 +1,796 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:busymax/src/core/secrets/secret_store.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/schedule/schedule_filters.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:busymax/src/schedule/schedule_repository.dart'; +import 'package:busymax/src/webcal/webcal_http_client.dart'; +import 'package:busymax/src/webcal/webcal_subscription_service.dart'; +import 'package:crypto/crypto.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late AppDatabase database; + late InMemorySecretStore secrets; + late _FakeTransport transport; + late WebCalSubscriptionService service; + late int notificationRebuilds; + late DateTime now; + + setUp(() { + now = DateTime.utc(2026, 8, 29, 20); + database = AppDatabase(NativeDatabase.memory()); + secrets = InMemorySecretStore(); + transport = _FakeTransport(); + notificationRebuilds = 0; + service = WebCalSubscriptionService( + database: database, + secretStore: secrets, + httpTransport: transport, + nowUtc: () => now, + idFactory: () => 'subscription-1', + onNotificationScheduleChanged: () async => notificationRebuilds += 1, + ); + }); + + tearDown(() async => database.close()); + + test( + 'add stores a redacted read-only snapshot with stable identities', + () async { + const credential = + 'https://calendar.example.test/feed?private-token=secret'; + transport.responses.add( + _response( + uri: Uri.parse(credential), + etag: '"one"', + body: utf8.encode( + _calendar(''' +SOURCE:$credential +BEGIN:VEVENT +UID:uid-one +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Published event +URL:$credential +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER:-PT15M +DESCRIPTION:Reminder +END:VALARM +END:VEVENT +'''), + ), + ), + ); + + final id = await service.addSubscription( + subscriptionUrl: credential, + localName: 'Team schedule', + color: '#336699', + refreshMode: WebCalRefreshMode.hourly, + ); + + expect(id, 'subscription-1'); + final account = await database.select(database.accounts).getSingle(); + expect(account.id, 'webcal-account-subscription-1'); + expect(account.provider, 'webcal'); + expect(account.credentialKind, 'webcal_subscription'); + expect(account.authority, 'https://calendar.example.test'); + expect(account.providerAccountId, isNot(contains('private-token'))); + expect(account.tasksEnabled, isFalse); + expect(await secrets.readActiveAccountId(), isNull); + + final source = await database + .select(database.calendarSources) + .getSingle(); + expect(source.id, 'webcal-calendar-subscription-1'); + expect(source.providerCalendarId, 'subscription-1'); + expect(source.readOnly, isTrue); + expect(source.accessRole, 'reader'); + expect(source.backgroundColor, '#336699'); + expect(source.rawJson, '{"transport":"webcal"}'); + + final subscription = await database + .select(database.webCalSubscriptions) + .getSingle(); + expect(subscription.safeOrigin, 'https://calendar.example.test'); + expect(subscription.snapshotIcsBody, isNot(contains(credential))); + expect(subscription.etag, '"one"'); + expect(subscription.refreshMode, 'hourly'); + expect( + DateTime.parse(subscription.nextRefreshAtUtc).difference(now), + greaterThanOrEqualTo(const Duration(hours: 1)), + ); + + final event = await database.select(database.calendarEvents).getSingle(); + final itemId = _stable('webcal-item', 'subscription-1\u0000uid-one'); + expect(event.providerEventId, itemId); + expect(event.id, _stable('webcal-event', '$itemId\u0000master')); + expect(event.syncStatus, 'synced'); + expect(event.webLink, isNull); + expect(event.rawJson, isNot(contains(credential))); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(notificationRebuilds, 1); + + final secret = + await secrets.readCredential(account.id) as WebCalSecretRecord; + expect(secret.normalizedSubscriptionUri, credential); + expect(secret.toString(), isNot(contains(credential))); + }, + ); + + test('duplicate add is rejected before another fetch', () async { + const uri = 'https://calendar.example.test/feed'; + transport.responses.add(_response(uri: Uri.parse(uri), body: _oneEvent)); + await service.addSubscription(subscriptionUrl: uri); + + await expectLater( + service.addSubscription( + subscriptionUrl: 'webcal://calendar.example.test/feed', + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalDuplicateSubscription', + ), + ), + ); + expect(transport.requests, hasLength(1)); + }); + + test( + 'snapshot removes original and validator-target URIs from parameters', + () async { + final origin = Uri.parse( + 'https://calendar.example.test/feed?private-token=one', + ); + final target = Uri.parse( + 'https://cdn.example.test/current.ics?private-token=two', + ); + transport.responses.add( + _response( + uri: target, + body: utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:parameter-secret +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Safe title +DESCRIPTION;ALTREP="${origin.toString()}":Origin +LOCATION;ALTREP="${target.toString()}":Target +END:VEVENT +'''), + ), + ), + ); + + await service.addSubscription(subscriptionUrl: origin.toString()); + + final snapshot = + (await database.select(database.webCalSubscriptions).getSingle()) + .snapshotIcsBody; + expect(snapshot, isNot(contains(origin.toString()))); + expect(snapshot, isNot(contains(target.toString()))); + expect(snapshot, isNot(contains('ALTREP'))); + }, + ); + + test( + 'escaped TEXT values cannot expose original or target credentials', + () async { + const original = r'https://calendar.example.test/feed?token=a,b\c'; + final target = Uri.parse( + 'https://cdn.example.test/current.ics?token=a;b', + ); + transport.responses.add( + _response( + uri: target, + body: utf8.encode( + _calendar(r''' +BEGIN:VEVENT +UID:text-secret +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:https://calendar.example.test/feed?token=a\,b\\c +DESCRIPTION:https://calendar.example.test/feed?token=a\,b\\c +LOCATION:https://cdn.example.test/current.ics?token=a\;b +END:VEVENT +'''), + ), + ), + ); + + await service.addSubscription(subscriptionUrl: original); + + final snapshot = await database + .select(database.webCalSubscriptions) + .getSingle(); + final event = await database.select(database.calendarEvents).getSingle(); + final projectedText = [ + event.title, + event.description, + event.location, + ].join('\n'); + expect(snapshot.snapshotIcsBody, isNot(contains(original))); + expect(snapshot.snapshotIcsBody, isNot(contains(target.toString()))); + expect(snapshot.snapshotIcsBody, isNot(contains('SUMMARY:'))); + expect(snapshot.snapshotIcsBody, isNot(contains('DESCRIPTION:'))); + expect(snapshot.snapshotIcsBody, isNot(contains('LOCATION:'))); + expect(projectedText, isNot(contains(original))); + expect(projectedText, isNot(contains(target.toString()))); + expect(event.title, isEmpty); + expect(event.description, isNull); + expect(event.location, isNull); + }, + ); + + test( + 'malformed refresh preserves snapshot, events, validators, and alarms', + () async { + const uri = 'https://calendar.example.test/feed'; + transport.responses.add( + _response(uri: Uri.parse(uri), etag: '"good"', body: _alarmedEvent), + ); + await service.addSubscription(subscriptionUrl: uri); + final beforeSubscription = await database + .select(database.webCalSubscriptions) + .getSingle(); + final beforeEvents = await database.select(database.calendarEvents).get(); + final beforeNotifications = await database + .select(database.notificationSchedule) + .get(); + + transport.responses.add( + _response( + uri: Uri.parse(uri), + etag: '"bad"', + body: utf8.encode('not a calendar'), + contentType: 'text/html', + ), + ); + await expectLater( + service.refreshSubscription('subscription-1', force: true), + throwsA(isA()), + ); + + final afterSubscription = await database + .select(database.webCalSubscriptions) + .getSingle(); + expect( + afterSubscription.snapshotIcsBody, + beforeSubscription.snapshotIcsBody, + ); + expect(afterSubscription.semanticHash, beforeSubscription.semanticHash); + expect(afterSubscription.etag, '"good"'); + expect( + afterSubscription.validatorTargetFingerprint, + beforeSubscription.validatorTargetFingerprint, + ); + expect(afterSubscription.lastFailureCode, 'WebCalContentTypeRejected'); + expect(afterSubscription.consecutiveFailureCount, 1); + expect( + (await database.select(database.calendarEvents).get()).map( + (row) => row.id, + ), + beforeEvents.map((row) => row.id), + ); + expect( + (await database.select(database.notificationSchedule).get()).map( + (row) => row.id, + ), + beforeNotifications.map((row) => row.id), + ); + expect(notificationRebuilds, 1); + }, + ); + + test( + 'refresh sanitizes the previous redirect target after rotation', + () async { + final origin = Uri.parse( + 'https://calendar.example.test/feed?token=original', + ); + final previousTarget = Uri.parse( + 'https://old-cdn.example.test/calendar.ics?token=old,private', + ); + final nextTarget = Uri.parse( + 'https://new-cdn.example.test/calendar.ics?token=new;private', + ); + transport.responses.add( + _response(uri: previousTarget, etag: '"one"', body: _oneEvent), + ); + await service.addSubscription(subscriptionUrl: origin.toString()); + + final escapedPrevious = previousTarget.toString().replaceAll(',', r'\,'); + transport.responses.add( + _response( + uri: nextTarget, + etag: '"two"', + body: utf8.encode( + _calendar(''' +SOURCE:${origin.toString()} +BEGIN:VEVENT +UID:uid-one +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Rotated feed +DESCRIPTION:$escapedPrevious +LOCATION;ALTREP="${previousTarget.toString()}":Office +URL:${nextTarget.toString()} +END:VEVENT +'''), + ), + ), + ); + + await service.refreshSubscription('subscription-1', force: true); + + final request = transport.requests.last; + expect(request.uri, origin); + expect(request.validatorTarget, previousTarget); + expect(request.validators.etag, '"one"'); + + final subscription = await database + .select(database.webCalSubscriptions) + .getSingle(); + final snapshot = subscription.snapshotIcsBody; + expect(snapshot, isNot(contains(origin.toString()))); + expect(snapshot, isNot(contains(previousTarget.toString()))); + expect(snapshot, isNot(contains(nextTarget.toString()))); + expect(snapshot, isNot(contains('DESCRIPTION:'))); + expect(snapshot, isNot(contains('LOCATION;'))); + expect(snapshot, isNot(contains('URL:'))); + + final event = await database.select(database.calendarEvents).getSingle(); + final projectedRow = jsonEncode(event.toJson()); + expect(projectedRow, isNot(contains(origin.toString()))); + expect(projectedRow, isNot(contains(previousTarget.toString()))); + expect(projectedRow, isNot(contains(nextTarget.toString()))); + expect(event.title, 'Rotated feed'); + expect(event.description, isNull); + expect(event.location, isNull); + expect(event.webLink, isNull); + + final committedSecret = + await secrets.readCredential('webcal-account-subscription-1') + as WebCalSecretRecord; + expect(committedSecret.validatorTargetUri, nextTarget); + }, + ); + + test('conditional 304 is accepted only for the bound target', () async { + final origin = Uri.parse('https://calendar.example.test/feed'); + final target = Uri.parse('https://cdn.example.test/current.ics'); + transport.responses.add( + _response(uri: target, etag: '"one"', body: _oneEvent), + ); + await service.addSubscription(subscriptionUrl: origin.toString()); + transport.responses.add( + WebCalHttpResponse( + statusCode: 304, + finalUri: target, + body: null, + etag: '"one"', + lastModified: null, + contentType: null, + conditionalRequestSent: true, + ), + ); + + await service.refreshSubscription('subscription-1', force: true); + + expect(transport.requests.last.uri, origin); + expect(transport.requests.last.validatorTarget, target); + expect(transport.requests.last.validators.etag, '"one"'); + expect( + (await database.select(database.webCalSubscriptions).getSingle()) + .consecutiveFailureCount, + 0, + ); + + transport.responses.add( + WebCalHttpResponse( + statusCode: 304, + finalUri: target, + body: null, + etag: null, + lastModified: null, + contentType: null, + conditionalRequestSent: false, + ), + ); + await expectLater( + service.refreshSubscription('subscription-1', force: true), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalInvalidNotModified', + ), + ), + ); + }); + + test('empty successful snapshot clears events atomically', () async { + final uri = Uri.parse('https://calendar.example.test/feed'); + transport.responses.add(_response(uri: uri, body: _oneEvent)); + await service.addSubscription(subscriptionUrl: uri.toString()); + expect(await database.select(database.calendarEvents).get(), isNotEmpty); + + transport.responses.add( + _response( + uri: uri, + body: utf8.encode( + _calendar(''' +BEGIN:VFREEBUSY +UID:freebusy +DTSTART:20260830T000000Z +DTEND:20260831T000000Z +END:VFREEBUSY +'''), + ), + ), + ); + await service.refreshSubscription('subscription-1', force: true); + + expect(await database.select(database.calendarEvents).get(), isEmpty); + expect( + (await database.select(database.webCalSubscriptions).getSingle()) + .snapshotIcsBody, + contains('VFREEBUSY'), + ); + }); + + test('single-event identity survives text and time changes', () async { + final uri = Uri.parse('https://calendar.example.test/feed'); + transport.responses.add(_response(uri: uri, body: _oneEvent)); + await service.addSubscription(subscriptionUrl: uri.toString()); + final before = await database.select(database.calendarEvents).getSingle(); + + transport.responses.add( + _response( + uri: uri, + body: utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:uid-one +DTSTART:20260902T180000Z +DTEND:20260902T193000Z +SEQUENCE:8 +SUMMARY:Changed title and time +END:VEVENT +'''), + ), + ), + ); + await service.refreshSubscription('subscription-1', force: true); + + final after = await database.select(database.calendarEvents).getSingle(); + expect(after.id, before.id); + expect(after.providerEventId, before.providerEventId); + expect(after.title, 'Changed title and time'); + expect(after.startDateTime, '2026-09-02T18:00:00.000Z'); + }); + + test( + 'due-aware refresh skips early work and force bypasses due time', + () async { + final uri = Uri.parse('https://calendar.example.test/feed'); + transport.responses.add( + _response( + uri: uri, + body: utf8.encode( + _calendar(''' +REFRESH-INTERVAL;VALUE=DURATION:PT12H +BEGIN:VEVENT +UID:uid-one +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:One +END:VEVENT +'''), + ), + ), + ); + await service.addSubscription(subscriptionUrl: uri.toString()); + final subscription = await database + .select(database.webCalSubscriptions) + .getSingle(); + expect(subscription.serverRefreshIntervalSeconds, 12 * 60 * 60); + expect( + DateTime.parse(subscription.nextRefreshAtUtc).difference(now), + greaterThanOrEqualTo(const Duration(hours: 12)), + ); + + await service.refreshSubscription('subscription-1'); + expect(transport.requests, hasLength(1)); + + transport.responses.add(_response(uri: uri, body: _oneEvent)); + await service.refreshSubscription('subscription-1', force: true); + expect(transport.requests, hasLength(2)); + }, + ); + + test('rename, color, and unsubscribe remain local and cascade', () async { + final uri = Uri.parse('https://calendar.example.test/feed'); + transport.responses.add(_response(uri: uri, body: _oneEvent)); + await service.addSubscription(subscriptionUrl: uri.toString()); + + await service.renameSubscription('subscription-1', 'Renamed'); + await service.changeSubscriptionColor('subscription-1', '#AABBCC'); + expect( + (await database.select(database.accounts).getSingle()).displayName, + 'Renamed', + ); + expect( + (await database.select(database.calendarSources).getSingle()).summary, + 'Renamed', + ); + expect( + (await database.select(database.calendarEvents).getSingle()).colorHex, + '#AABBCC', + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + + await service.unsubscribe('subscription-1'); + expect(await database.select(database.accounts).get(), isEmpty); + expect(await database.select(database.calendarSources).get(), isEmpty); + expect(await database.select(database.webCalSubscriptions).get(), isEmpty); + expect(await database.select(database.calendarEvents).get(), isEmpty); + expect( + await secrets.readCredential('webcal-account-subscription-1'), + isNull, + ); + }); + + test( + 'unsubscribe waits for an in-flight refresh and cannot restore secret', + () async { + final uri = Uri.parse('https://calendar.example.test/feed'); + transport.responses.add(_response(uri: uri, body: _oneEvent)); + await service.addSubscription(subscriptionUrl: uri.toString()); + + final response = Completer(); + transport.responses.add(response.future); + final refresh = service.refreshSubscription( + 'subscription-1', + force: true, + ); + while (transport.requests.length < 2) { + await Future.delayed(Duration.zero); + } + final unsubscribe = service.unsubscribe('subscription-1'); + var unsubscribeFinished = false; + unawaited(unsubscribe.whenComplete(() => unsubscribeFinished = true)); + await Future.delayed(Duration.zero); + expect(unsubscribeFinished, isFalse); + + response.complete(_response(uri: uri, etag: '"two"', body: _oneEvent)); + await refresh; + await unsubscribe; + + expect(await database.select(database.accounts).get(), isEmpty); + expect( + await database.select(database.webCalSubscriptions).get(), + isEmpty, + ); + expect( + await secrets.readCredential('webcal-account-subscription-1'), + isNull, + ); + }, + ); + + test('covered schedule reads do not wait for an in-flight refresh', () async { + final uri = Uri.parse('https://calendar.example.test/feed'); + transport.responses.add(_response(uri: uri, body: _oneEvent)); + await service.addSubscription(subscriptionUrl: uri.toString()); + + final response = Completer(); + transport.responses.add(response.future); + final refresh = service.refreshSubscription('subscription-1', force: true); + while (transport.requests.length < 2) { + await Future.delayed(Duration.zero); + } + + final repository = ScheduleRepository( + database, + ensureProjectionCoverage: (range) => service.ensureProjectionCoverage( + rangeStartUtc: range.start.toUtc(), + rangeEndUtc: range.end.toUtc(), + ), + ); + final items = await repository + .listItems( + range: ScheduleRange.day(DateTime.utc(2026, 8, 30)), + filters: const ScheduleFilters(includeTasks: false), + ) + .timeout(const Duration(seconds: 1)); + + expect(items.map((item) => item.title), ['One']); + expect(response.isCompleted, isFalse); + + response.complete(_response(uri: uri, body: _oneEvent)); + await refresh; + }); + + test('304 reprojects an aging cached recurring snapshot', () async { + final uri = Uri.parse('https://calendar.example.test/feed'); + transport.responses.add( + _response( + uri: uri, + etag: '"one"', + body: utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:long-series +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +RRULE:FREQ=YEARLY;COUNT=20 +SUMMARY:Annual event +END:VEVENT +'''), + ), + ), + ); + await service.addSubscription(subscriptionUrl: uri.toString()); + expect( + (await database.select(database.calendarEvents).get()).any( + (event) => event.startDateTime?.startsWith('2031-') == true, + ), + isFalse, + ); + + now = DateTime.utc(2029, 8, 29, 20); + transport.responses.add( + WebCalHttpResponse( + statusCode: 304, + finalUri: uri, + body: null, + etag: '"one"', + lastModified: null, + contentType: null, + conditionalRequestSent: true, + ), + ); + await service.refreshSubscription('subscription-1', force: true); + + final events = await database.select(database.calendarEvents).get(); + expect( + events.any((event) => event.startDateTime?.startsWith('2031-') == true), + isTrue, + ); + final subscription = await database + .select(database.webCalSubscriptions) + .getSingle(); + expect(subscription.projectionVersion, webCalProjectionVersion); + expect( + DateTime.parse( + subscription.projectionRangeEndUtc!, + ).isAfter(DateTime.utc(2031)), + isTrue, + ); + expect(transport.requests.last.uri, uri); + expect(notificationRebuilds, 2); + }); + + test( + 'requested schedule range extends projection without a network fetch', + () async { + final uri = Uri.parse('https://calendar.example.test/feed'); + transport.responses.add( + _response( + uri: uri, + body: utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:long-series +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +RRULE:FREQ=YEARLY;COUNT=20 +SUMMARY:Annual event +END:VEVENT +'''), + ), + ), + ); + await service.addSubscription(subscriptionUrl: uri.toString()); + + await service.ensureProjectionCoverage( + rangeStartUtc: DateTime.utc(2035, 8, 1), + rangeEndUtc: DateTime.utc(2035, 9, 1), + ); + + expect(transport.requests, hasLength(1)); + expect( + (await database.select(database.calendarEvents).get()).any( + (event) => event.startDateTime?.startsWith('2035-') == true, + ), + isTrue, + ); + }, + ); +} + +final class _FakeTransport implements WebCalHttpTransport { + final responses = []; + final requests = + <({Uri uri, WebCalHttpValidators validators, Uri? validatorTarget})>[]; + + @override + Future get( + Uri uri, { + WebCalHttpValidators validators = const WebCalHttpValidators(), + Uri? validatorTarget, + }) async { + requests.add(( + uri: uri, + validators: validators, + validatorTarget: validatorTarget, + )); + final next = responses.removeAt(0); + if (next is WebCalHttpResponse) return next; + if (next is Future) return next; + throw next; + } +} + +WebCalHttpResponse _response({ + required Uri uri, + required List body, + String? etag, + String? contentType = 'text/calendar; charset=utf-8', +}) => WebCalHttpResponse( + statusCode: 200, + finalUri: uri, + body: Uint8List.fromList(body), + etag: etag, + lastModified: null, + contentType: contentType, + conditionalRequestSent: false, +); + +List get _oneEvent => utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:uid-one +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:One +END:VEVENT +'''), +); + +List get _alarmedEvent => utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:uid-alarm +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Alarmed +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER:-PT15M +DESCRIPTION:Reminder +END:VALARM +END:VEVENT +'''), +); + +String _calendar(String components) => + '''BEGIN:VCALENDAR\r +VERSION:2.0\r +PRODID:-//BusyMax WebCal Test//EN\r +${components.trim().replaceAll('\n', '\r\n')}\r +END:VCALENDAR\r +'''; + +String _stable(String prefix, String input) => + '$prefix-${sha256.convert(utf8.encode(input))}'; diff --git a/test/webcal/webcal_uri_test.dart b/test/webcal/webcal_uri_test.dart new file mode 100644 index 00000000..7d6d99f5 --- /dev/null +++ b/test/webcal/webcal_uri_test.dart @@ -0,0 +1,49 @@ +import 'package:busymax/src/webcal/webcal_uri.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('normalizes WebCal without changing credential components', () { + final normalized = normalizeWebCalUri( + ' webcal://Example.COM:8443/path%2Fcalendar?b=2&a=one%20two ', + ); + + expect( + normalized.uri.toString(), + 'https://example.com:8443/path%2Fcalendar?b=2&a=one%20two', + ); + expect(normalized.safeOrigin.toString(), 'https://example.com:8443'); + expect(normalized.fingerprint, webCalUriFingerprint(normalized.uri)); + }); + + test('default HTTPS port is omitted only from the safe origin', () { + final normalized = normalizeWebCalUri( + 'https://example.test:443/calendar?token=private', + ); + + expect(normalized.credentialUri, contains(':443/calendar')); + expect(normalized.safeOrigin.toString(), 'https://example.test'); + }); + + for (final invalid in [ + 'http://example.test/calendar', + 'ftp://example.test/calendar', + 'https://user:secret@example.test/calendar', + 'https://example.test/calendar#fragment', + 'webcal:calendar', + 'https://example.test:not-a-port/feed', + 'https://example.test:70000/feed', + ]) { + test('rejects $invalid', () { + expect( + () => normalizeWebCalUri(invalid), + throwsA(isA()), + ); + }); + } + + test('fingerprints distinguish exact normalized subscription targets', () { + final first = normalizeWebCalUri('webcal://example.test/feed?a=1&b=2'); + final second = normalizeWebCalUri('https://example.test/feed?b=2&a=1'); + expect(first.fingerprint, isNot(second.fingerprint)); + }); +} diff --git a/third_party/flutter_timezone/LICENSE b/third_party/flutter_timezone/LICENSE new file mode 100644 index 00000000..8dada3ed --- /dev/null +++ b/third_party/flutter_timezone/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/flutter_timezone/README.busymax.md b/third_party/flutter_timezone/README.busymax.md new file mode 100644 index 00000000..355622db --- /dev/null +++ b/third_party/flutter_timezone/README.busymax.md @@ -0,0 +1,6 @@ +# BusyMax flutter_timezone packaging + +This directory contains the Dart API and Windows implementation from upstream +`flutter_timezone` 5.1.0 under its original license. Other platform plugin +registrations are intentionally omitted because BusyMax Linux retains its +existing `/etc/timezone`, `/etc/localtime`, and GWeather behavior. diff --git a/third_party/flutter_timezone/lib/flutter_timezone.dart b/third_party/flutter_timezone/lib/flutter_timezone.dart new file mode 100644 index 00000000..63cfb823 --- /dev/null +++ b/third_party/flutter_timezone/lib/flutter_timezone.dart @@ -0,0 +1,53 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_timezone/timezone_info.dart'; + +export 'package:flutter_timezone/timezone_info.dart'; + +/// +/// Class for getting the native timezone. +/// +class FlutterTimezone { + static const _channel = MethodChannel('flutter_timezone'); + + /// + /// Returns local timezone from the native layer. + /// + /// On supported platforms (see README), you can optionally provide a locale code (e.g. "en_US", "de") to get the + /// localized name of the timezone in that locale, if provided by the underlying platform. + static Future getLocalTimezone([String? locale]) async { + final localTimezone = + await _channel.invokeMethod('getLocalTimezone', locale); + if (localTimezone == null) { + throw ArgumentError('Invalid return from platform getLocalTimezone()'); + } + if (localTimezone is String) { + return TimezoneInfo(identifier: localTimezone); + } + return TimezoneInfo.fromJson(localTimezone); + } + + /// + /// Gets the list of available timezones from the native layer. + /// + /// On supported platforms (see README), you can optionally provide a locale code (e.g. "en_US", "de") to get the + /// localized names of the timezones in that locale, if provided by the underlying platform. + static Future> getAvailableTimezones([ + String? locale, + ]) async { + final availableTimezones = + await _channel.invokeListMethod('getAvailableTimezones', locale); + if (availableTimezones == null) { + throw ArgumentError( + 'Invalid return from platform getAvailableTimezones()', + ); + } + return availableTimezones.map((timezone) { + if (timezone is String) { + return TimezoneInfo(identifier: timezone); + } + return TimezoneInfo.fromJson(timezone); + }).toList(); + } +} diff --git a/third_party/flutter_timezone/lib/timezone_info.dart b/third_party/flutter_timezone/lib/timezone_info.dart new file mode 100644 index 00000000..4981f9a8 --- /dev/null +++ b/third_party/flutter_timezone/lib/timezone_info.dart @@ -0,0 +1,35 @@ +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; + +@immutable +// Upstream 5.1.0 uses this mixin; keep its public equality behavior intact. +// ignore: deprecated_member_use +class TimezoneInfo with EquatableMixin { + TimezoneInfo({required this.identifier, this.localizedName}); + + factory TimezoneInfo.fromJson(Map json) { + final localizedName = json['localizedName']; + return TimezoneInfo( + identifier: json['identifier'] as String, + localizedName: localizedName == null + ? null + : (name: localizedName, locale: json['locale']), + ); + } + + /// The standardized IANA identifier for this timezone, e.g. "America/Los_Angeles", as reported by the underlying + /// platform. + final String identifier; + + /// The localized name for this timezone, e.g. "Central European Standard Time", as reported by the + /// underlying platform. + /// + /// This is available if: + /// - flutter_timezone supports localized names on the current platform (see README for details). + /// - The requested locale exists on the current platform. + /// - The current platform provides a localized name for the current timezone in the requested locale. + final ({String name, String locale})? localizedName; + + @override + List get props => [identifier, localizedName]; +} diff --git a/third_party/flutter_timezone/pubspec.yaml b/third_party/flutter_timezone/pubspec.yaml new file mode 100644 index 00000000..3409b085 --- /dev/null +++ b/third_party/flutter_timezone/pubspec.yaml @@ -0,0 +1,24 @@ +name: flutter_timezone +description: Windows-only BusyMax packaging of the upstream timezone plugin. +homepage: https://github.com/tjarvstrand/flutter_timezone +version: 5.1.0 +publish_to: none + +platforms: + windows: + +environment: + sdk: ">=3.4.0 <4.0.0" + flutter: ">=3.22.0" + +dependencies: + equatable: ^2.0.7 + flutter: + sdk: flutter + meta: ^1.16.0 + +flutter: + plugin: + platforms: + windows: + pluginClass: FlutterTimezonePluginCApi diff --git a/third_party/flutter_timezone/windows/CMakeLists.txt b/third_party/flutter_timezone/windows/CMakeLists.txt new file mode 100644 index 00000000..eeac1318 --- /dev/null +++ b/third_party/flutter_timezone/windows/CMakeLists.txt @@ -0,0 +1,106 @@ +# The Flutter tooling requires that developers have a version of Visual Studio +# installed that includes CMake 3.14 or later. You should not increase this +# version, as doing so will cause the plugin to fail to compile for some +# customers of the plugin. +cmake_minimum_required(VERSION 3.14) + +# Project-level configuration. +set(PROJECT_NAME "flutter_timezone") +project(${PROJECT_NAME} LANGUAGES CXX) + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "flutter_timezone_plugin") + +# Any new source files that you add to the plugin should be added here. +list(APPEND PLUGIN_SOURCES + "flutter_timezone_plugin.cpp" + "flutter_timezone_plugin.h" + "timezone_native_api.cpp" + "timezone_native_api.h" +) + +# Define the plugin library target. Its name must not be changed (see comment +# on PLUGIN_NAME above). +add_library(${PLUGIN_NAME} SHARED + "include/flutter_timezone/flutter_timezone_plugin_c_api.h" + "flutter_timezone_plugin_c_api.cpp" + ${PLUGIN_SOURCES} +) + +# Apply a standard set of build settings that are configured in the +# application-level CMakeLists.txt. This can be removed for plugins that want +# full control over build settings. +apply_standard_settings(${PLUGIN_NAME}) + +# Symbols are hidden by default to reduce the chance of accidental conflicts +# between plugins. This should not be removed; any symbols that should be +# exported should be explicitly exported with the FLUTTER_PLUGIN_EXPORT macro. +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) + +# Source include directories and library dependencies. Add any plugin-specific +# dependencies here. +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) +target_link_libraries(${PLUGIN_NAME} PRIVATE icu) + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(flutter_timezone_bundled_libraries + "" + PARENT_SCOPE +) + +# === Tests === +# These unit tests can be run from a terminal after building the example, or +# from Visual Studio after opening the generated solution file. + +# Only enable test builds when building the example (which sets this variable) +# so that plugin clients aren't building the tests. +if (${include_${PROJECT_NAME}_tests}) +set(TEST_RUNNER "${PROJECT_NAME}_test") +enable_testing() + +# Add the Google Test dependency. +include(FetchContent) +FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/refs/tags/v1.18.0.zip + URL_HASH + SHA256=63b9c77751a5b8f492486005f67533fdc58682b67476fcb91650be5958d5195a +) +# Prevent overriding the parent project's compiler/linker settings +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +# Disable install commands for gtest so it doesn't end up in the bundle. +set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE) +FetchContent_MakeAvailable(googletest) + +# The plugin's C API is not very useful for unit testing, so build the sources +# directly into the test binary rather than using the DLL. +add_executable(${TEST_RUNNER} + test/flutter_timezone_plugin_test.cpp + ${PLUGIN_SOURCES} +) +apply_standard_settings(${TEST_RUNNER}) +target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") +target_link_libraries(${TEST_RUNNER} PRIVATE flutter_wrapper_plugin) +target_link_libraries(${TEST_RUNNER} PRIVATE icu) +target_link_libraries(${TEST_RUNNER} PRIVATE gtest_main gmock) +# flutter_wrapper_plugin has link dependencies on the Flutter DLL. +add_custom_command(TARGET ${TEST_RUNNER} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${FLUTTER_LIBRARY}" $ +) + +# Enable automatic test discovery. +include(GoogleTest) +gtest_discover_tests(${TEST_RUNNER}) +endif() diff --git a/third_party/flutter_timezone/windows/flutter_timezone_plugin.cpp b/third_party/flutter_timezone/windows/flutter_timezone_plugin.cpp new file mode 100644 index 00000000..46e13d37 --- /dev/null +++ b/third_party/flutter_timezone/windows/flutter_timezone_plugin.cpp @@ -0,0 +1,84 @@ +#include "flutter_timezone_plugin.h" + +#include +#include +#include + +#include +#include + +namespace flutter_timezone { + +void FlutterTimezonePlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar) { + auto channel = + std::make_unique>( + registrar->messenger(), "flutter_timezone", + &flutter::StandardMethodCodec::GetInstance()); + auto plugin = std::make_unique(); + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto& call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + registrar->AddPlugin(std::move(plugin)); +} + +FlutterTimezonePlugin::FlutterTimezonePlugin() + : owned_api_(std::make_unique()), + api_(owned_api_.get()) {} + +FlutterTimezonePlugin::FlutterTimezonePlugin(TimezoneNativeApi* api) + : api_(api) {} + +FlutterTimezonePlugin::~FlutterTimezonePlugin() = default; + +void FlutterTimezonePlugin::HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + if (method_call.method_name() == kGetLocalTimezone) { + GetLocalTimezone(std::move(result)); + return; + } + if (method_call.method_name() == kGetAvailableTimezones) { + GetAvailableTimezones(std::move(result)); + return; + } + result->NotImplemented(); +} + +void FlutterTimezonePlugin::GetLocalTimezone( + std::unique_ptr> result) { + if (api_ == nullptr) { + result->Error("timezone-native-api-unavailable", + "The Windows time-zone service is unavailable."); + return; + } + const TimezoneOperationResult operation = ResolveLocalTimezone(*api_); + if (!operation.succeeded) { + result->Error(operation.error_code, operation.error_message); + return; + } + result->Success(flutter::EncodableValue(operation.value)); +} + +void FlutterTimezonePlugin::GetAvailableTimezones( + std::unique_ptr> result) { + if (api_ == nullptr) { + result->Error("timezone-native-api-unavailable", + "The Windows time-zone service is unavailable."); + return; + } + const TimezoneOperationResult operation = EnumerateTimezones(*api_); + if (!operation.succeeded) { + result->Error(operation.error_code, operation.error_message); + return; + } + flutter::EncodableList values; + values.reserve(operation.values.size()); + for (const std::string& value : operation.values) { + values.emplace_back(value); + } + result->Success(flutter::EncodableValue(values)); +} + +} // namespace flutter_timezone diff --git a/third_party/flutter_timezone/windows/flutter_timezone_plugin.h b/third_party/flutter_timezone/windows/flutter_timezone_plugin.h new file mode 100644 index 00000000..f97f6bd5 --- /dev/null +++ b/third_party/flutter_timezone/windows/flutter_timezone_plugin.h @@ -0,0 +1,45 @@ +#ifndef FLUTTER_PLUGIN_FLUTTER_TIMEZONE_PLUGIN_H_ +#define FLUTTER_PLUGIN_FLUTTER_TIMEZONE_PLUGIN_H_ + +#include +#include + +#include + +#include "timezone_native_api.h" + +namespace flutter_timezone { + constexpr auto kGetLocalTimezone = "getLocalTimezone"; + constexpr auto kGetAvailableTimezones = "getAvailableTimezones"; + + class FlutterTimezonePlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); + + FlutterTimezonePlugin(); + + // Visible for native tests. The API must outlive the plugin. + explicit FlutterTimezonePlugin(TimezoneNativeApi* api); + + virtual ~FlutterTimezonePlugin(); + + // Disallow copy and assign. + FlutterTimezonePlugin(const FlutterTimezonePlugin&) = delete; + FlutterTimezonePlugin& operator=(const FlutterTimezonePlugin&) = delete; + + // Called when a method is called on this plugin's channel from Dart. + void HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + + private: + void GetLocalTimezone(std::unique_ptr> result); + void GetAvailableTimezones(std::unique_ptr> result); + + std::unique_ptr owned_api_; + TimezoneNativeApi* api_ = nullptr; + }; + +} // namespace flutter_timezone + +#endif // FLUTTER_PLUGIN_FLUTTER_TIMEZONE_PLUGIN_H_ diff --git a/third_party/flutter_timezone/windows/flutter_timezone_plugin_c_api.cpp b/third_party/flutter_timezone/windows/flutter_timezone_plugin_c_api.cpp new file mode 100644 index 00000000..1bad969e --- /dev/null +++ b/third_party/flutter_timezone/windows/flutter_timezone_plugin_c_api.cpp @@ -0,0 +1,12 @@ +#include "include/flutter_timezone/flutter_timezone_plugin_c_api.h" + +#include + +#include "flutter_timezone_plugin.h" + +void FlutterTimezonePluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + flutter_timezone::FlutterTimezonePlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/third_party/flutter_timezone/windows/include/flutter_timezone/flutter_timezone_plugin_c_api.h b/third_party/flutter_timezone/windows/include/flutter_timezone/flutter_timezone_plugin_c_api.h new file mode 100644 index 00000000..3067f40b --- /dev/null +++ b/third_party/flutter_timezone/windows/include/flutter_timezone/flutter_timezone_plugin_c_api.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_FLUTTER_TIMEZONE_PLUGIN_C_API_H_ +#define FLUTTER_PLUGIN_FLUTTER_TIMEZONE_PLUGIN_C_API_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void FlutterTimezonePluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_FLUTTER_TIMEZONE_PLUGIN_C_API_H_ diff --git a/third_party/flutter_timezone/windows/test/flutter_timezone_plugin_test.cpp b/third_party/flutter_timezone/windows/test/flutter_timezone_plugin_test.cpp new file mode 100644 index 00000000..9604a26d --- /dev/null +++ b/third_party/flutter_timezone/windows/test/flutter_timezone_plugin_test.cpp @@ -0,0 +1,206 @@ +#include "flutter_timezone_plugin.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace flutter_timezone { +namespace { + +class FakeTimezoneApi final : public TimezoneNativeApi { + public: + DWORD query_result = TIME_ZONE_ID_STANDARD; + int geo_result = 3; + int32_t mapping_result = 17; + UErrorCode mapping_status = U_ZERO_ERROR; + UErrorCode open_status = U_ZERO_ERROR; + UErrorCode count_status = U_ZERO_ERROR; + UErrorCode next_status = U_ZERO_ERROR; + int32_t count_result = 2; + bool return_enumeration = true; + int close_count = 0; + size_t next_index = 0; + std::wstring windows_id = L"Pacific Standard Time"; + std::wstring mapped_id = L"America/Vancouver"; + std::vector enumerated = {"Etc/UTC", "America/Vancouver"}; + + DWORD GetDynamicTimeZoneInformation( + DYNAMIC_TIME_ZONE_INFORMATION* information) override { + if (information != nullptr && query_result != TIME_ZONE_ID_INVALID) { + wcsncpy_s(information->TimeZoneKeyName, + _countof(information->TimeZoneKeyName), windows_id.c_str(), + _TRUNCATE); + } + return query_result; + } + + int GetUserDefaultGeoName(wchar_t* buffer, int capacity) override { + if (geo_result > 0 && buffer != nullptr && capacity >= geo_result) { + buffer[0] = L'C'; + buffer[1] = L'A'; + buffer[2] = L'\0'; + } + return geo_result; + } + + int32_t GetTimeZoneIdForWindowsId(const UChar*, + int32_t, + const char*, + UChar* result, + int32_t result_capacity, + UErrorCode* status) override { + *status = mapping_status; + if (mapping_result > 0 && U_SUCCESS(*status) && result != nullptr && + result_capacity > mapping_result) { + std::copy_n(mapped_id.data(), mapping_result, result); + } + return mapping_result; + } + + UEnumeration* OpenTimeZoneIdEnumeration(UErrorCode* status) override { + *status = open_status; + return return_enumeration ? reinterpret_cast(this) : nullptr; + } + + int32_t EnumerationCount(UEnumeration*, UErrorCode* status) override { + *status = count_status; + return count_result; + } + + const char* EnumerationNext(UEnumeration*, + int32_t* result_length, + UErrorCode* status) override { + *status = next_status; + if (U_FAILURE(*status) || next_index >= enumerated.size()) { + *result_length = 0; + return nullptr; + } + const std::string& current = enumerated[next_index++]; + *result_length = static_cast(current.size()); + return current.data(); + } + + void CloseEnumeration(UEnumeration*) override { ++close_count; } +}; + +struct CompletionCounts { + int success = 0; + int error = 0; + int not_implemented = 0; + + int total() const { return success + error + not_implemented; } +}; + +CompletionCounts Invoke(FlutterTimezonePlugin& plugin, + const std::string& method) { + CompletionCounts counts{}; + flutter::MethodCall call(method, nullptr); + auto result = std::make_unique< + flutter::MethodResultFunctions>( + [&counts](const flutter::EncodableValue*) { ++counts.success; }, + [&counts](const std::string&, const std::string&, + const flutter::EncodableValue*) { ++counts.error; }, + [&counts]() { ++counts.not_implemented; }); + plugin.HandleMethodCall(call, std::move(result)); + return counts; +} + +TEST(FlutterTimezonePluginTest, MapsWindowsTimezoneToIana) { + FakeTimezoneApi api; + const auto result = ResolveLocalTimezone(api); + EXPECT_TRUE(result.succeeded); + EXPECT_EQ(result.value, "America/Vancouver"); +} + +TEST(FlutterTimezonePluginTest, RejectsUnavailableGeographicalInformation) { + FakeTimezoneApi api; + api.geo_result = 0; + const auto result = ResolveLocalTimezone(api); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "windows-region-unavailable"); +} + +TEST(FlutterTimezonePluginTest, RejectsFailedWindowsTimezoneQuery) { + FakeTimezoneApi api; + api.query_result = TIME_ZONE_ID_INVALID; + const auto result = ResolveLocalTimezone(api); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "windows-timezone-query-failed"); +} + +TEST(FlutterTimezonePluginTest, RejectsMissingOrZeroLengthIcuMapping) { + FakeTimezoneApi api; + api.mapping_result = 0; + const auto result = ResolveLocalTimezone(api); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "icu-timezone-mapping-missing"); +} + +TEST(FlutterTimezonePluginTest, RejectsIcuErrorStatus) { + FakeTimezoneApi api; + api.mapping_status = U_INTERNAL_PROGRAM_ERROR; + const auto result = ResolveLocalTimezone(api); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "icu-timezone-mapping-failed"); +} + +TEST(FlutterTimezonePluginTest, EnumerationOpenFailureDoesNotUseOrCloseHandle) { + FakeTimezoneApi api; + api.return_enumeration = false; + api.open_status = U_MEMORY_ALLOCATION_ERROR; + const auto result = EnumerateTimezones(api); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "icu-timezone-enumeration-open-failed"); + EXPECT_EQ(api.close_count, 0); +} + +TEST(FlutterTimezonePluginTest, EnumerationOpenErrorClosesReturnedHandle) { + FakeTimezoneApi api; + api.open_status = U_INTERNAL_PROGRAM_ERROR; + const auto result = EnumerateTimezones(api); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "icu-timezone-enumeration-open-failed"); + EXPECT_EQ(api.close_count, 1); +} + +TEST(FlutterTimezonePluginTest, EnumerationReadFailureClosesHandleExactlyOnce) { + FakeTimezoneApi api; + api.next_status = U_INTERNAL_PROGRAM_ERROR; + const auto result = EnumerateTimezones(api); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "icu-timezone-enumeration-read-failed"); + EXPECT_EQ(api.close_count, 1); +} + +TEST(FlutterTimezonePluginTest, EveryMethodPathCompletesExactlyOnce) { + FakeTimezoneApi success_api; + FlutterTimezonePlugin success_plugin(&success_api); + EXPECT_EQ(Invoke(success_plugin, kGetLocalTimezone).total(), 1); + EXPECT_EQ(Invoke(success_plugin, kGetAvailableTimezones).total(), 1); + + FakeTimezoneApi failure_api; + failure_api.mapping_status = U_INTERNAL_PROGRAM_ERROR; + failure_api.open_status = U_INTERNAL_PROGRAM_ERROR; + FlutterTimezonePlugin failure_plugin(&failure_api); + const CompletionCounts local = Invoke(failure_plugin, kGetLocalTimezone); + const CompletionCounts available = + Invoke(failure_plugin, kGetAvailableTimezones); + EXPECT_EQ(local.total(), 1); + EXPECT_EQ(local.error, 1); + EXPECT_EQ(available.total(), 1); + EXPECT_EQ(available.error, 1); + + const CompletionCounts unknown = Invoke(success_plugin, "unknown"); + EXPECT_EQ(unknown.total(), 1); + EXPECT_EQ(unknown.not_implemented, 1); +} + +} // namespace +} // namespace flutter_timezone diff --git a/third_party/flutter_timezone/windows/timezone_native_api.cpp b/third_party/flutter_timezone/windows/timezone_native_api.cpp new file mode 100644 index 00000000..e99735dd --- /dev/null +++ b/third_party/flutter_timezone/windows/timezone_native_api.cpp @@ -0,0 +1,212 @@ +#include "timezone_native_api.h" + +#include +#include +#include + +namespace flutter_timezone { +namespace { + +// GetUserDefaultGeoName requires space for at least 85 UTF-16 code units. +// GEO_NAME_LENGTH is not exposed by every supported Windows SDK header set, +// so keep the documented capacity local to this narrow Win32 adapter. +constexpr size_t kGeoNameCapacity = 85; + +TimezoneOperationResult Failure(const char* code, const char* message) { + TimezoneOperationResult result{}; + result.error_code = code; + result.error_message = message; + return result; +} + +bool WideToUtf8(const wchar_t* value, + int32_t length, + std::string* converted) { + if (value == nullptr || converted == nullptr || length <= 0) { + return false; + } + const int required = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value, + length, nullptr, 0, nullptr, nullptr); + if (required <= 0) { + return false; + } + converted->assign(static_cast(required), '\0'); + return WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value, length, + converted->data(), required, nullptr, nullptr) == + required; +} + +class EnumerationOwner { + public: + EnumerationOwner(TimezoneNativeApi& api, UEnumeration* value) + : api_(api), value_(value) {} + ~EnumerationOwner() { + if (value_ != nullptr) { + api_.CloseEnumeration(value_); + } + } + UEnumeration* get() const { return value_; } + + EnumerationOwner(const EnumerationOwner&) = delete; + EnumerationOwner& operator=(const EnumerationOwner&) = delete; + + private: + TimezoneNativeApi& api_; + UEnumeration* value_; +}; + +} // namespace + +DWORD WindowsTimezoneNativeApi::GetDynamicTimeZoneInformation( + DYNAMIC_TIME_ZONE_INFORMATION* information) { + return ::GetDynamicTimeZoneInformation(information); +} + +int WindowsTimezoneNativeApi::GetUserDefaultGeoName(wchar_t* buffer, + int capacity) { + return ::GetUserDefaultGeoName(buffer, capacity); +} + +int32_t WindowsTimezoneNativeApi::GetTimeZoneIdForWindowsId( + const UChar* windows_id, + int32_t windows_id_length, + const char* region, + UChar* result, + int32_t result_capacity, + UErrorCode* status) { + return ucal_getTimeZoneIDForWindowsID(windows_id, windows_id_length, region, + result, result_capacity, status); +} + +UEnumeration* WindowsTimezoneNativeApi::OpenTimeZoneIdEnumeration( + UErrorCode* status) { + return ucal_openTimeZoneIDEnumeration(UCAL_ZONE_TYPE_CANONICAL, nullptr, + nullptr, status); +} + +int32_t WindowsTimezoneNativeApi::EnumerationCount( + UEnumeration* enumeration, + UErrorCode* status) { + return uenum_count(enumeration, status); +} + +const char* WindowsTimezoneNativeApi::EnumerationNext( + UEnumeration* enumeration, + int32_t* result_length, + UErrorCode* status) { + return uenum_next(enumeration, result_length, status); +} + +void WindowsTimezoneNativeApi::CloseEnumeration(UEnumeration* enumeration) { + uenum_close(enumeration); +} + +TimezoneOperationResult ResolveLocalTimezone(TimezoneNativeApi& api) { + DYNAMIC_TIME_ZONE_INFORMATION information{}; + if (api.GetDynamicTimeZoneInformation(&information) == + TIME_ZONE_ID_INVALID) { + return Failure("windows-timezone-query-failed", + "Windows could not provide its current time zone."); + } + + const size_t key_length = wcsnlen_s(information.TimeZoneKeyName, + _countof(information.TimeZoneKeyName)); + if (key_length == 0 || + key_length >= _countof(information.TimeZoneKeyName) || + key_length > + static_cast((std::numeric_limits::max)())) { + return Failure("windows-timezone-key-invalid", + "Windows returned an invalid time-zone key."); + } + + std::array geo_buffer{}; + const int geo_length = api.GetUserDefaultGeoName( + geo_buffer.data(), static_cast(geo_buffer.size())); + if (geo_length <= 0 || geo_length > static_cast(geo_buffer.size())) { + return Failure("windows-region-unavailable", + "Windows could not provide the current geographic region."); + } + // The Win32 length includes the terminator. Never infer a length by reading + // beyond what the API reported. + const int explicit_geo_length = + geo_buffer[static_cast(geo_length - 1)] == L'\0' + ? geo_length - 1 + : geo_length; + if (explicit_geo_length <= 0) { + return Failure("windows-region-empty", + "Windows returned an empty geographic region."); + } + std::string region; + if (!WideToUtf8(geo_buffer.data(), explicit_geo_length, ®ion)) { + return Failure("windows-region-encoding-failed", + "Windows returned an invalid geographic region."); + } + + std::array mapped{}; + UErrorCode status = U_ZERO_ERROR; + const int32_t mapped_length = api.GetTimeZoneIdForWindowsId( + information.TimeZoneKeyName, static_cast(key_length), + region.c_str(), mapped.data(), static_cast(mapped.size()), + &status); + if (U_FAILURE(status)) { + return Failure("icu-timezone-mapping-failed", + "ICU could not map the Windows time zone."); + } + if (mapped_length <= 0) { + return Failure("icu-timezone-mapping-missing", + "No IANA mapping exists for the Windows time zone."); + } + if (mapped_length >= static_cast(mapped.size())) { + return Failure("icu-timezone-mapping-too-long", + "The mapped IANA time zone exceeded the safe limit."); + } + + std::string identifier; + if (!WideToUtf8(mapped.data(), mapped_length, &identifier)) { + return Failure("icu-timezone-encoding-failed", + "ICU returned an invalid IANA time zone."); + } + TimezoneOperationResult result{}; + result.succeeded = true; + result.value = std::move(identifier); + return result; +} + +TimezoneOperationResult EnumerateTimezones(TimezoneNativeApi& api) { + UErrorCode status = U_ZERO_ERROR; + UEnumeration* raw_enumeration = api.OpenTimeZoneIdEnumeration(&status); + if (raw_enumeration == nullptr) { + return Failure("icu-timezone-enumeration-open-failed", + "ICU could not open the time-zone catalog."); + } + EnumerationOwner enumeration(api, raw_enumeration); + if (U_FAILURE(status)) { + return Failure("icu-timezone-enumeration-open-failed", + "ICU could not open the time-zone catalog."); + } + + status = U_ZERO_ERROR; + const int32_t count = api.EnumerationCount(enumeration.get(), &status); + if (U_FAILURE(status) || count < 0) { + return Failure("icu-timezone-enumeration-count-failed", + "ICU could not count the available time zones."); + } + + TimezoneOperationResult result{}; + result.values.reserve(static_cast(count)); + for (int32_t index = 0; index < count; ++index) { + status = U_ZERO_ERROR; + int32_t value_length = 0; + const char* value = + api.EnumerationNext(enumeration.get(), &value_length, &status); + if (U_FAILURE(status) || value == nullptr || value_length <= 0) { + return Failure("icu-timezone-enumeration-read-failed", + "ICU could not read the time-zone catalog."); + } + result.values.emplace_back(value, static_cast(value_length)); + } + result.succeeded = true; + return result; +} + +} // namespace flutter_timezone diff --git a/third_party/flutter_timezone/windows/timezone_native_api.h b/third_party/flutter_timezone/windows/timezone_native_api.h new file mode 100644 index 00000000..2cc36733 --- /dev/null +++ b/third_party/flutter_timezone/windows/timezone_native_api.h @@ -0,0 +1,74 @@ +#ifndef FLUTTER_TIMEZONE_TIMEZONE_NATIVE_API_H_ +#define FLUTTER_TIMEZONE_TIMEZONE_NATIVE_API_H_ + +// Windows must precede ICU headers. +#include + +#define UCHAR_TYPE wchar_t +#include + +#include +#include +#include + +namespace flutter_timezone { + +struct TimezoneOperationResult { + bool succeeded = false; + std::string value; + std::vector values; + std::string error_code; + std::string error_message; +}; + +// Kept behind an interface so every Win32 and ICU failure can be exercised by +// native tests without changing process or machine state. +class TimezoneNativeApi { + public: + virtual ~TimezoneNativeApi() = default; + + virtual DWORD GetDynamicTimeZoneInformation( + DYNAMIC_TIME_ZONE_INFORMATION* information) = 0; + virtual int GetUserDefaultGeoName(wchar_t* buffer, int capacity) = 0; + virtual int32_t GetTimeZoneIdForWindowsId( + const UChar* windows_id, + int32_t windows_id_length, + const char* region, + UChar* result, + int32_t result_capacity, + UErrorCode* status) = 0; + virtual UEnumeration* OpenTimeZoneIdEnumeration(UErrorCode* status) = 0; + virtual int32_t EnumerationCount(UEnumeration* enumeration, + UErrorCode* status) = 0; + virtual const char* EnumerationNext(UEnumeration* enumeration, + int32_t* result_length, + UErrorCode* status) = 0; + virtual void CloseEnumeration(UEnumeration* enumeration) = 0; +}; + +class WindowsTimezoneNativeApi final : public TimezoneNativeApi { + public: + DWORD GetDynamicTimeZoneInformation( + DYNAMIC_TIME_ZONE_INFORMATION* information) override; + int GetUserDefaultGeoName(wchar_t* buffer, int capacity) override; + int32_t GetTimeZoneIdForWindowsId(const UChar* windows_id, + int32_t windows_id_length, + const char* region, + UChar* result, + int32_t result_capacity, + UErrorCode* status) override; + UEnumeration* OpenTimeZoneIdEnumeration(UErrorCode* status) override; + int32_t EnumerationCount(UEnumeration* enumeration, + UErrorCode* status) override; + const char* EnumerationNext(UEnumeration* enumeration, + int32_t* result_length, + UErrorCode* status) override; + void CloseEnumeration(UEnumeration* enumeration) override; +}; + +TimezoneOperationResult ResolveLocalTimezone(TimezoneNativeApi& api); +TimezoneOperationResult EnumerateTimezones(TimezoneNativeApi& api); + +} // namespace flutter_timezone + +#endif // FLUTTER_TIMEZONE_TIMEZONE_NATIVE_API_H_ diff --git a/third_party/tray_manager/LICENSE b/third_party/tray_manager/LICENSE new file mode 100644 index 00000000..46e9d56a --- /dev/null +++ b/third_party/tray_manager/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-present LiJianying + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/third_party/tray_manager/README.busymax.md b/third_party/tray_manager/README.busymax.md new file mode 100644 index 00000000..cc4eeea9 --- /dev/null +++ b/third_party/tray_manager/README.busymax.md @@ -0,0 +1,7 @@ +# BusyMax tray_manager packaging + +This directory contains the API and Windows implementation from upstream +`tray_manager` 0.5.3 under its original license. The Linux and macOS plugin +registrations are intentionally omitted: BusyMax Linux retains its existing +DBus/XDG tray implementation, while this package is used only by the Windows +composition. diff --git a/third_party/tray_manager/lib/src/helpers/sandbox.dart b/third_party/tray_manager/lib/src/helpers/sandbox.dart new file mode 100644 index 00000000..602c42d5 --- /dev/null +++ b/third_party/tray_manager/lib/src/helpers/sandbox.dart @@ -0,0 +1,9 @@ +import 'dart:io'; + +/// Returns `true` if the app is running in a sandbox, eg. Flatpak, Snap, Docker, Podman. +bool runningInSandbox() { + return Platform.environment.containsKey('FLATPAK_ID') || + Platform.environment.containsKey('SNAP') || + (Platform.environment['container']?.isNotEmpty == true) || + FileSystemEntity.isFileSync('/.dockerenv'); +} diff --git a/third_party/tray_manager/lib/src/tray_listener.dart b/third_party/tray_manager/lib/src/tray_listener.dart new file mode 100644 index 00000000..add190f0 --- /dev/null +++ b/third_party/tray_manager/lib/src/tray_listener.dart @@ -0,0 +1,15 @@ +import 'package:menu_base/menu_base.dart'; + +abstract mixin class TrayListener { + /// Emitted when the mouse clicks the tray icon. + void onTrayIconMouseDown() {} + + /// Emitted when the mouse is released from clicking the tray icon. + void onTrayIconMouseUp() {} + + void onTrayIconRightMouseDown() {} + + void onTrayIconRightMouseUp() {} + + void onTrayMenuItemClick(MenuItem menuItem) {} +} diff --git a/third_party/tray_manager/lib/src/tray_manager.dart b/third_party/tray_manager/lib/src/tray_manager.dart new file mode 100644 index 00000000..f23b61c6 --- /dev/null +++ b/third_party/tray_manager/lib/src/tray_manager.dart @@ -0,0 +1,225 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:menu_base/menu_base.dart'; +import 'package:path/path.dart' as path; +import 'package:shortid/shortid.dart'; +import 'package:tray_manager/src/helpers/sandbox.dart'; +import 'package:tray_manager/src/tray_listener.dart'; + +const kEventOnTrayIconMouseDown = 'onTrayIconMouseDown'; +const kEventOnTrayIconMouseUp = 'onTrayIconMouseUp'; +const kEventOnTrayIconRightMouseDown = 'onTrayIconRightMouseDown'; +const kEventOnTrayIconRightMouseUp = 'onTrayIconRightMouseUp'; +const kEventOnTrayMenuItemClick = 'onTrayMenuItemClick'; + +enum TrayIconPosition { left, right } + +class TrayManager { + TrayManager._() { + _channel.setMethodCallHandler(_methodCallHandler); + } + + /// The shared instance of [TrayManager]. + static final TrayManager instance = TrayManager._(); + + final MethodChannel _channel = const MethodChannel('tray_manager'); + + final ObserverList _listeners = ObserverList(); + + double get _devicePixelRatio { + final flutterView = WidgetsBinding.instance.platformDispatcher.views.single; + return MediaQueryData.fromView(flutterView).devicePixelRatio; + } + + Menu? _menu; + + Future _methodCallHandler(MethodCall call) async { + for (final TrayListener listener in _listeners) { + switch (call.method) { + case kEventOnTrayIconMouseDown: + listener.onTrayIconMouseDown(); + break; + case kEventOnTrayIconMouseUp: + listener.onTrayIconMouseUp(); + break; + case kEventOnTrayIconRightMouseDown: + listener.onTrayIconRightMouseDown(); + break; + case kEventOnTrayIconRightMouseUp: + listener.onTrayIconRightMouseUp(); + break; + case kEventOnTrayMenuItemClick: + int id = call.arguments['id']; + MenuItem? menuItem = _menu?.getMenuItemById(id); + if (menuItem != null) { + bool? oldChecked = menuItem.checked; + if (menuItem.onClick != null) { + menuItem.onClick?.call(menuItem); + } + listener.onTrayMenuItemClick(menuItem); + + bool? newChecked = menuItem.checked; + if (oldChecked != newChecked) { + await setContextMenu(_menu!); + } + } + break; + } + } + } + + /// Whether any listeners are currently registered. + bool get hasListeners { + return _listeners.isNotEmpty; + } + + /// Register a closure to be called when the tray events. + void addListener(TrayListener listener) { + _listeners.add(listener); + } + + /// Remove a previously registered closure from the list of closures that are + /// notified when the tray events. + void removeListener(TrayListener listener) { + _listeners.remove(listener); + } + + // Destroys the tray icon immediately. + Future destroy() async { + await _channel.invokeMethod('destroy'); + } + + /// Sets the image associated with this tray icon. + /// + /// [iconPath] is the path to the image file. + /// + /// However, if the app is running in a sandbox like Flatpak or Snap, + /// [iconPath] should be the name of the icon as specified in the app's + /// manifest file, without the path or file extension. For example, if the + /// icon is specified as `org.example.app` in the Flatpak manifest file, then + /// the icon should be passed as `org.example.app`. + Future setIcon( + String iconPath, { + bool isTemplate = false, // macOS only + TrayIconPosition iconPosition = TrayIconPosition.left, // macOS only + int iconSize = 18, // macOS only + }) async { + final Map arguments = { + 'id': shortid.generate(), + 'iconPath': path.joinAll([ + path.dirname(Platform.resolvedExecutable), + 'data/flutter_assets', + iconPath, + ]), + 'isTemplate': isTemplate, + 'iconPosition': iconPosition.name, + 'iconSize': iconSize, + }; + + switch (defaultTargetPlatform) { + case TargetPlatform.linux: + if (runningInSandbox()) { + // Pass the icon name as specified if running in a sandbox. + // + // This is required because when running in a sandbox, paths are not + // the same as seen by the app and the host system. + arguments['iconPath'] = iconPath; + } + break; + case TargetPlatform.macOS: + // Add the icon as base64 string + ByteData imageData = await rootBundle.load(iconPath); + String base64Icon = base64Encode(imageData.buffer.asUint8List()); + arguments['base64Icon'] = base64Icon; + break; + default: + break; + } + + await _channel.invokeMethod('setIcon', arguments); + } + + /// Sets the icon position of the tray icon. + /// + /// @platforms macos + Future setIconPosition(TrayIconPosition trayIconPosition) async { + final arguments = { + 'iconPosition': trayIconPosition.name, + }; + await _channel.invokeMethod('setIconPosition', arguments); + } + + /// Sets the hover text for this tray icon. + /// + /// Must be called after the icon is set. + /// ```dart + /// await trayManager.setIcon(...); + /// await trayManager.setToolTip(...); + /// ``` + Future setToolTip(String toolTip) async { + final Map arguments = { + 'toolTip': toolTip, + }; + await _channel.invokeMethod('setToolTip', arguments); + } + + /// Sets the title for this tray icon. + Future setTitle(String title) async { + final Map arguments = { + 'title': title, + }; + await _channel.invokeMethod('setTitle', arguments); + } + + /// Sets the context menu for this icon. + Future setContextMenu(Menu menu) async { + _menu = menu; + final Map arguments = { + 'menu': menu.toJson(), + }; + await _channel.invokeMethod('setContextMenu', arguments); + } + + /// Pops up the context menu of the tray icon. + /// + /// [bringAppToFront] If true, the app will be brought to the front when the + /// context menu is shown. Only works on Windows. + Future popUpContextMenu({ + @Deprecated( + 'This parameter is only supported on Windows and will be removed in the future.', + ) + bool bringAppToFront = false, + }) async { + final Map arguments = { + 'bringAppToFront': bringAppToFront, + }; + await _channel.invokeMethod('popUpContextMenu', arguments); + } + + /// The bounds of this tray icon. + Future getBounds() async { + final Map arguments = { + 'devicePixelRatio': _devicePixelRatio, + }; + final Map? resultData = await _channel.invokeMethod( + 'getBounds', + arguments, + ); + if (resultData == null) { + return null; + } + return Rect.fromLTWH( + resultData['x'], + resultData['y'], + resultData['width'], + resultData['height'], + ); + } +} + +final trayManager = TrayManager.instance; diff --git a/third_party/tray_manager/lib/tray_manager.dart b/third_party/tray_manager/lib/tray_manager.dart new file mode 100644 index 00000000..ae4fd5ab --- /dev/null +++ b/third_party/tray_manager/lib/tray_manager.dart @@ -0,0 +1,4 @@ +export 'package:menu_base/menu_base.dart'; + +export 'src/tray_listener.dart'; +export 'src/tray_manager.dart'; diff --git a/third_party/tray_manager/pubspec.yaml b/third_party/tray_manager/pubspec.yaml new file mode 100644 index 00000000..49c1877a --- /dev/null +++ b/third_party/tray_manager/pubspec.yaml @@ -0,0 +1,25 @@ +name: tray_manager +description: Windows-only BusyMax packaging of the upstream tray_manager API. +homepage: https://github.com/leanflutter/tray_manager +version: 0.5.3 +publish_to: none + +platforms: + windows: + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.3.0" + +dependencies: + flutter: + sdk: flutter + menu_base: ^0.1.0 + path: ^1.8.0 + shortid: ^0.1.2 + +flutter: + plugin: + platforms: + windows: + pluginClass: TrayManagerPlugin diff --git a/third_party/tray_manager/windows/CMakeLists.txt b/third_party/tray_manager/windows/CMakeLists.txt new file mode 100644 index 00000000..ac9a94eb --- /dev/null +++ b/third_party/tray_manager/windows/CMakeLists.txt @@ -0,0 +1,56 @@ +cmake_minimum_required(VERSION 3.15) +set(PROJECT_NAME "tray_manager") +project(${PROJECT_NAME} LANGUAGES CXX) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "tray_manager_plugin") + +add_library(${PLUGIN_NAME} SHARED + "tray_manager_plugin.cpp" + "tray_native_api.cpp" + "tray_native_api.h" +) +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_compile_definitions(${PLUGIN_NAME} PRIVATE _SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE + flutter flutter_wrapper_plugin "shell32.lib" "user32.lib") + +# List of absolute paths to libraries that should be bundled with the plugin +set(tray_manager_bundled_libraries + "" + PARENT_SCOPE +) + +if (${include_${PROJECT_NAME}_tests}) + set(TEST_RUNNER "${PROJECT_NAME}_test") + enable_testing() + include(FetchContent) + FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/refs/tags/v1.18.0.zip + URL_HASH + SHA256=63b9c77751a5b8f492486005f67533fdc58682b67476fcb91650be5958d5195a + ) + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE) + FetchContent_MakeAvailable(googletest) + add_executable(${TEST_RUNNER} + "test/tray_native_api_test.cpp" + "tray_native_api.cpp" + "tray_native_api.h" + ) + apply_standard_settings(${TEST_RUNNER}) + target_include_directories(${TEST_RUNNER} PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}" + ) + target_link_libraries(${TEST_RUNNER} PRIVATE + gtest_main gmock "shell32.lib" "user32.lib") + include(GoogleTest) + gtest_discover_tests(${TEST_RUNNER}) +endif() diff --git a/third_party/tray_manager/windows/include/tray_manager/tray_manager_plugin.h b/third_party/tray_manager/windows/include/tray_manager/tray_manager_plugin.h new file mode 100644 index 00000000..036416aa --- /dev/null +++ b/third_party/tray_manager/windows/include/tray_manager/tray_manager_plugin.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_TRAY_MANAGER_PLUGIN_H_ +#define FLUTTER_PLUGIN_TRAY_MANAGER_PLUGIN_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void TrayManagerPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_TRAY_MANAGER_PLUGIN_H_ diff --git a/third_party/tray_manager/windows/test/tray_native_api_test.cpp b/third_party/tray_manager/windows/test/tray_native_api_test.cpp new file mode 100644 index 00000000..ea3b5127 --- /dev/null +++ b/third_party/tray_manager/windows/test/tray_native_api_test.cpp @@ -0,0 +1,212 @@ +#include "tray_native_api.h" + +#include + +#include +#include + +namespace tray_manager { +namespace { + +class FakeTrayApi final : public TrayNativeApi { + public: + HICON loaded_icon = reinterpret_cast(static_cast(0x100)); + bool destroy_result = true; + bool add_result = true; + bool modify_result = true; + bool delete_result = true; + bool version_result = true; + HRESULT rect_result = S_OK; + std::vector destroyed; + std::vector notify_calls; + std::wstring loaded_path; + std::wstring modified_tooltip; + HMENU created_menu = reinterpret_cast(static_cast(0x300)); + bool append_result = true; + bool destroy_menu_result = true; + bool cursor_result = true; + bool foreground_result = true; + bool track_result = true; + int destroyed_menus = 0; + + HICON LoadIconFile(const wchar_t* path, int, int) override { + loaded_path = path == nullptr ? L"" : path; + return loaded_icon; + } + + bool DestroyIconHandle(HICON icon) override { + destroyed.push_back(icon); + return destroy_result; + } + + bool NotifyIcon(DWORD message, NOTIFYICONDATAW* data) override { + notify_calls.push_back(message); + if (message == NIM_MODIFY && data != nullptr) { + modified_tooltip = data->szTip; + } + if (message == NIM_ADD) return add_result; + if (message == NIM_MODIFY) return modify_result; + if (message == NIM_DELETE) return delete_result; + if (message == NIM_SETVERSION) return version_result; + return false; + } + + HRESULT NotifyIconGetRect(const NOTIFYICONIDENTIFIER*, + RECT* rect) override { + if (SUCCEEDED(rect_result) && rect != nullptr) { + *rect = {1, 2, 11, 22}; + } + return rect_result; + } + + HMENU CreatePopupMenuHandle() override { return created_menu; } + bool AppendMenuItem(HMENU, UINT, UINT_PTR, const wchar_t*) override { + return append_result; + } + bool DestroyMenuHandle(HMENU) override { + ++destroyed_menus; + return destroy_menu_result; + } + bool GetCursorPosition(POINT* point) override { + if (cursor_result && point != nullptr) *point = {1, 2}; + return cursor_result; + } + bool SetForegroundWindowHandle(HWND) override { return foreground_result; } + bool TrackMenu(HMENU, int, int, HWND) override { return track_result; } + + DWORD LastError() const override { return ERROR_GEN_FAILURE; } +}; + +const HWND kWindow = reinterpret_cast(static_cast(0x200)); + +TEST(TrayCallbackTest, DecodesVersionFourEventAndIconIdentifier) { + const LPARAM value = MAKELPARAM(WM_RBUTTONUP, 1); + EXPECT_EQ(TrayCallbackEvent(value), WM_RBUTTONUP); + EXPECT_EQ(TrayCallbackIconId(value), 1u); +} + +TEST(TrayIconControllerTest, CreatesTrayAndNegotiatesCurrentVersion) { + FakeTrayApi api; + TrayIconController controller(api); + const auto result = controller.SetIcon(kWindow, L"busymax.ico"); + EXPECT_TRUE(result.succeeded); + EXPECT_TRUE(controller.available()); + EXPECT_EQ(api.notify_calls, (std::vector{NIM_ADD, NIM_SETVERSION})); + EXPECT_TRUE(api.destroyed.empty()); +} + +TEST(TrayIconControllerTest, RejectsMissingOrCorruptIcon) { + FakeTrayApi api; + api.loaded_icon = nullptr; + TrayIconController controller(api); + const auto result = controller.SetIcon(kWindow, L"missing.ico"); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "tray-icon-load-failed"); + EXPECT_FALSE(controller.available()); + EXPECT_TRUE(api.destroyed.empty()); +} + +TEST(TrayIconControllerTest, AddFailureDoesNotClaimAvailability) { + FakeTrayApi api; + api.add_result = false; + TrayIconController controller(api); + const auto result = controller.SetIcon(kWindow, L"busymax.ico"); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "tray-add-failed"); + EXPECT_FALSE(controller.available()); + EXPECT_EQ(api.destroyed, (std::vector{api.loaded_icon})); +} + +TEST(TrayIconControllerTest, ReplacesIconOnlyAfterSuccessfulModify) { + FakeTrayApi api; + TrayIconController controller(api); + const HICON first = api.loaded_icon; + ASSERT_TRUE(controller.SetIcon(kWindow, L"first.ico").succeeded); + const HICON second = + reinterpret_cast(static_cast(0x101)); + api.loaded_icon = second; + ASSERT_TRUE(controller.SetIcon(kWindow, L"second.ico").succeeded); + EXPECT_EQ(api.destroyed, (std::vector{first})); + + const HICON rejected = + reinterpret_cast(static_cast(0x102)); + api.loaded_icon = rejected; + api.modify_result = false; + EXPECT_FALSE(controller.SetIcon(kWindow, L"rejected.ico").succeeded); + EXPECT_TRUE(controller.available()); + EXPECT_EQ(api.destroyed, (std::vector{first, rejected})); +} + +TEST(TrayIconControllerTest, PreservesTooltipAcrossIconReplacement) { + FakeTrayApi api; + TrayIconController controller(api); + ASSERT_TRUE(controller.SetIcon(kWindow, L"first.ico").succeeded); + ASSERT_TRUE(controller.SetTooltip(L"BusyMax offline").succeeded); + api.loaded_icon = + reinterpret_cast(static_cast(0x101)); + ASSERT_TRUE(controller.SetIcon(kWindow, L"second.ico").succeeded); + EXPECT_EQ(controller.tooltip(), L"BusyMax offline"); + EXPECT_EQ(api.modified_tooltip, L"BusyMax offline"); +} + +TEST(TrayIconControllerTest, ExplorerRecoveryReportsFailureAndSuccess) { + FakeTrayApi api; + TrayIconController controller(api); + ASSERT_TRUE(controller.SetIcon(kWindow, L"busymax.ico").succeeded); + api.add_result = false; + EXPECT_FALSE(controller.Recover(kWindow).succeeded); + EXPECT_FALSE(controller.available()); + api.add_result = true; + EXPECT_TRUE(controller.Recover(kWindow).succeeded); + EXPECT_TRUE(controller.available()); +} + +TEST(TrayIconControllerTest, DestroyAndRecreateReleasesEachIconOnce) { + FakeTrayApi api; + TrayIconController controller(api); + const HICON first = api.loaded_icon; + ASSERT_TRUE(controller.SetIcon(kWindow, L"first.ico").succeeded); + ASSERT_TRUE(controller.Destroy().succeeded); + EXPECT_EQ(api.destroyed, (std::vector{first})); + api.loaded_icon = + reinterpret_cast(static_cast(0x101)); + ASSERT_TRUE(controller.SetIcon(kWindow, L"second.ico").succeeded); + EXPECT_TRUE(controller.available()); +} + +TEST(TrayIconControllerTest, GetBoundsFailureIsPropagated) { + FakeTrayApi api; + TrayIconController controller(api); + ASSERT_TRUE(controller.SetIcon(kWindow, L"busymax.ico").succeeded); + api.rect_result = E_FAIL; + RECT rect{}; + const auto result = controller.GetBounds(&rect); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "tray-bounds-query-failed"); +} + +TEST(TrayMenuControllerTest, MenuCreationFailureIsPropagated) { + FakeTrayApi api; + api.created_menu = nullptr; + TrayMenuController menu(api); + const auto result = menu.SetItems({}); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "tray-menu-create-failed"); + EXPECT_FALSE(menu.available()); +} + +TEST(TrayMenuControllerTest, MenuInsertionFailureDestroysReplacementOnce) { + FakeTrayApi api; + api.append_result = false; + TrayMenuController menu(api); + TrayMenuItem item; + item.id = 1; + item.label = L"Open BusyMax"; + const auto result = menu.SetItems({item}); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "tray-menu-insert-failed"); + EXPECT_EQ(api.destroyed_menus, 1); +} + +} // namespace +} // namespace tray_manager diff --git a/third_party/tray_manager/windows/tray_manager_plugin.cpp b/third_party/tray_manager/windows/tray_manager_plugin.cpp new file mode 100644 index 00000000..d69b0925 --- /dev/null +++ b/third_party/tray_manager/windows/tray_manager_plugin.cpp @@ -0,0 +1,411 @@ +#include "include/tray_manager/tray_manager_plugin.h" + +#include "tray_native_api.h" + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#define WM_MYMESSAGE (WM_USER + 1) + +namespace { + +const flutter::EncodableValue* ValueOrNull(const flutter::EncodableMap& map, + const char* key) { + const auto found = map.find(flutter::EncodableValue(key)); + return found == map.end() ? nullptr : &found->second; +} + +std::unique_ptr> channel; +std::unique_ptr> health_channel; + +class TrayManagerPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); + explicit TrayManagerPlugin(flutter::PluginRegistrarWindows* registrar); + ~TrayManagerPlugin() override; + + private: + flutter::PluginRegistrarWindows* registrar_; + tray_manager::WindowsTrayNativeApi native_api_; + tray_manager::TrayIconController tray_{native_api_}; + tray_manager::TrayMenuController menu_{native_api_}; + std::wstring_convert> converter_; + UINT taskbar_created_message_ = 0; + int window_proc_id_ = -1; + + HWND GetMainWindow() const; + void ReportAvailability(const tray_manager::TrayOperationResult& operation); + bool BuildMenu(const flutter::EncodableMap& args, + std::vector* menu, + std::string* error); + std::optional HandleWindowProc(HWND window, + UINT message, + WPARAM wparam, + LPARAM lparam); + void HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + void Destroy( + std::unique_ptr> result); + void SetIcon( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + void SetToolTip( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + void SetContextMenu( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + void PopUpContextMenu( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + void GetBounds( + const flutter::MethodCall& method_call, + std::unique_ptr> result); +}; + +bool plugin_already_registered = false; + +void TrayManagerPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar) { + if (plugin_already_registered) { + return; + } + plugin_already_registered = true; + channel = std::make_unique>( + registrar->messenger(), "tray_manager", + &flutter::StandardMethodCodec::GetInstance()); + health_channel = + std::make_unique>( + registrar->messenger(), "busymax/windows/tray_health", + &flutter::StandardMethodCodec::GetInstance()); + auto plugin = std::make_unique(registrar); + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto& call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + registrar->AddPlugin(std::move(plugin)); +} + +TrayManagerPlugin::TrayManagerPlugin( + flutter::PluginRegistrarWindows* registrar) + : registrar_(registrar) { + window_proc_id_ = registrar_->RegisterTopLevelWindowProcDelegate( + [this](HWND window, UINT message, WPARAM wparam, LPARAM lparam) { + return HandleWindowProc(window, message, wparam, lparam); + }); + taskbar_created_message_ = RegisterWindowMessageW(L"TaskbarCreated"); +} + +TrayManagerPlugin::~TrayManagerPlugin() { + tray_.Destroy(); + if (window_proc_id_ >= 0) { + registrar_->UnregisterTopLevelWindowProcDelegate(window_proc_id_); + } +} + +HWND TrayManagerPlugin::GetMainWindow() const { + return ::GetAncestor(registrar_->GetView()->GetNativeWindow(), GA_ROOT); +} + +void TrayManagerPlugin::ReportAvailability( + const tray_manager::TrayOperationResult& operation) { + if (!health_channel) { + return; + } + flutter::EncodableMap event; + event[flutter::EncodableValue("available")] = + flutter::EncodableValue(tray_.available()); + if (!operation.succeeded) { + event[flutter::EncodableValue("errorCode")] = + flutter::EncodableValue(operation.error_code); + } + health_channel->InvokeMethod( + "onTrayAvailabilityChanged", + std::make_unique(event)); +} + +bool TrayManagerPlugin::BuildMenu(const flutter::EncodableMap& args, + std::vector* menu, + std::string* error) { + const auto items_it = args.find(flutter::EncodableValue("items")); + if (menu == nullptr || items_it == args.end() || + !std::holds_alternative(items_it->second)) { + *error = "tray-menu-invalid"; + return false; + } + const auto& items = std::get(items_it->second); + for (const flutter::EncodableValue& item_value : items) { + if (!std::holds_alternative(item_value)) { + *error = "tray-menu-item-invalid"; + return false; + } + const auto& item = std::get(item_value); + try { + const int id = + std::get(item.at(flutter::EncodableValue("id"))); + const std::string type = + std::get(item.at(flutter::EncodableValue("type"))); + const std::string label = + std::get(item.at(flutter::EncodableValue("label"))); + const bool disabled = + std::get(item.at(flutter::EncodableValue("disabled"))); + tray_manager::TrayMenuItem parsed; + parsed.id = id; + parsed.label = converter_.from_bytes(label); + parsed.disabled = disabled; + if (type == "separator") { + parsed.type = tray_manager::TrayMenuItemType::kSeparator; + } else if (type == "checkbox") { + const bool* checked = std::get_if(ValueOrNull(item, "checked")); + if (checked == nullptr) { + *error = "tray-menu-checkbox-invalid"; + return false; + } + parsed.type = tray_manager::TrayMenuItemType::kCheckbox; + parsed.checked = *checked; + } else if (type == "submenu") { + parsed.type = tray_manager::TrayMenuItemType::kSubmenu; + const auto submenu_it = item.find(flutter::EncodableValue("submenu")); + if (submenu_it == item.end() || + !std::holds_alternative(submenu_it->second) || + !BuildMenu(std::get(submenu_it->second), + &parsed.children, error)) { + return false; + } + } else if (type != "normal") { + *error = "tray-menu-type-invalid"; + return false; + } + menu->push_back(std::move(parsed)); + } catch (const std::exception&) { + *error = "tray-menu-value-invalid"; + return false; + } + } + return true; +} + +std::optional TrayManagerPlugin::HandleWindowProc(HWND window, + UINT message, + WPARAM wparam, + LPARAM lparam) { + if (message == WM_DESTROY) { + const auto operation = tray_.Destroy(); + ReportAvailability(operation); + } else if (message == WM_COMMAND) { + flutter::EncodableMap event; + event[flutter::EncodableValue("id")] = + flutter::EncodableValue(static_cast(LOWORD(wparam))); + channel->InvokeMethod("onTrayMenuItemClick", + std::make_unique(event)); + } else if (message == WM_MYMESSAGE) { + if (tray_manager::TrayCallbackIconId(lparam) != 1) { + return std::nullopt; + } + const UINT event = tray_manager::TrayCallbackEvent(lparam); + if (event == WM_LBUTTONUP || event == NIN_SELECT || + event == NIN_KEYSELECT) { + channel->InvokeMethod("onTrayIconMouseDown", + std::make_unique()); + } else if (event == WM_RBUTTONUP || event == WM_CONTEXTMENU) { + channel->InvokeMethod("onTrayIconRightMouseDown", + std::make_unique()); + } + } else if (message == taskbar_created_message_ && + taskbar_created_message_ != 0) { + const auto operation = tray_.Recover(GetMainWindow()); + ReportAvailability(operation); + } else if (message == WM_POWERBROADCAST && + (wparam == PBT_APMRESUMEAUTOMATIC || + wparam == PBT_APMRESUMESUSPEND) && + tray_.available()) { + const auto operation = tray_.Recover(GetMainWindow()); + ReportAvailability(operation); + } + return std::nullopt; +} + +void TrayManagerPlugin::Destroy( + std::unique_ptr> result) { + const auto operation = tray_.Destroy(); + ReportAvailability(operation); + if (!operation.succeeded) { + result->Error(operation.error_code, operation.error_message); + return; + } + result->Success(flutter::EncodableValue(true)); +} + +void TrayManagerPlugin::SetIcon( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + try { + const auto& args = + std::get(*method_call.arguments()); + const std::string path = + std::get(args.at(flutter::EncodableValue("iconPath"))); + const auto operation = tray_.SetIcon(GetMainWindow(), + converter_.from_bytes(path)); + ReportAvailability(operation); + if (!operation.succeeded) { + result->Error(operation.error_code, operation.error_message); + return; + } + result->Success(flutter::EncodableValue(true)); + } catch (const std::exception&) { + result->Error("tray-icon-arguments-invalid", + "The BusyMax tray icon path was invalid."); + } +} + +void TrayManagerPlugin::SetToolTip( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + try { + const auto& args = + std::get(*method_call.arguments()); + const std::string tooltip = + std::get(args.at(flutter::EncodableValue("toolTip"))); + const auto operation = tray_.SetTooltip(converter_.from_bytes(tooltip)); + if (!operation.succeeded) { + result->Error(operation.error_code, operation.error_message); + return; + } + result->Success(flutter::EncodableValue(true)); + } catch (const std::exception&) { + result->Error("tray-tooltip-invalid", + "The BusyMax tray tooltip was invalid."); + } +} + +void TrayManagerPlugin::SetContextMenu( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + std::vector parsed; + std::string error; + try { + const auto& args = + std::get(*method_call.arguments()); + const auto& menu_args = std::get( + args.at(flutter::EncodableValue("menu"))); + if (!BuildMenu(menu_args, &parsed, &error)) { + result->Error(error, "Windows could not build the BusyMax tray menu."); + return; + } + } catch (const std::exception&) { + result->Error("tray-menu-arguments-invalid", + "The BusyMax tray menu was invalid."); + return; + } + const auto operation = menu_.SetItems(parsed); + if (!operation.succeeded) { + result->Error(operation.error_code, operation.error_message); + return; + } + result->Success(flutter::EncodableValue(true)); +} + +void TrayManagerPlugin::PopUpContextMenu( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + if (!tray_.available() || !menu_.available()) { + result->Error("tray-menu-unavailable", + "The BusyMax tray menu is not available."); + return; + } + bool bring_to_front = false; + try { + const auto& args = + std::get(*method_call.arguments()); + bring_to_front = std::get( + args.at(flutter::EncodableValue("bringAppToFront"))); + } catch (const std::exception&) { + result->Error("tray-menu-arguments-invalid", + "The BusyMax tray menu request was invalid."); + return; + } + const auto operation = menu_.Show(GetMainWindow(), bring_to_front); + if (!operation.succeeded) { + result->Error(operation.error_code, operation.error_message); + return; + } + result->Success(flutter::EncodableValue(true)); +} + +void TrayManagerPlugin::GetBounds( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + double scale = 0; + try { + const auto& args = + std::get(*method_call.arguments()); + scale = std::get( + args.at(flutter::EncodableValue("devicePixelRatio"))); + } catch (const std::exception&) { + result->Error("tray-bounds-arguments-invalid", + "The BusyMax tray scale value was invalid."); + return; + } + if (scale <= 0) { + result->Error("tray-bounds-scale-invalid", + "The BusyMax tray scale value must be positive."); + return; + } + RECT rect{}; + const auto operation = tray_.GetBounds(&rect); + if (!operation.succeeded) { + result->Error(operation.error_code, operation.error_message); + return; + } + flutter::EncodableMap bounds; + bounds[flutter::EncodableValue("x")] = + flutter::EncodableValue(rect.left / scale); + bounds[flutter::EncodableValue("y")] = + flutter::EncodableValue(rect.top / scale); + bounds[flutter::EncodableValue("width")] = + flutter::EncodableValue((rect.right - rect.left) / scale); + bounds[flutter::EncodableValue("height")] = + flutter::EncodableValue((rect.bottom - rect.top) / scale); + result->Success(flutter::EncodableValue(bounds)); +} + +void TrayManagerPlugin::HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + const std::string& name = method_call.method_name(); + if (name == "destroy") { + Destroy(std::move(result)); + } else if (name == "setIcon") { + SetIcon(method_call, std::move(result)); + } else if (name == "setToolTip") { + SetToolTip(method_call, std::move(result)); + } else if (name == "setContextMenu") { + SetContextMenu(method_call, std::move(result)); + } else if (name == "popUpContextMenu") { + PopUpContextMenu(method_call, std::move(result)); + } else if (name == "getBounds") { + GetBounds(method_call, std::move(result)); + } else { + result->NotImplemented(); + } +} + +} // namespace + +void TrayManagerPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + TrayManagerPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/third_party/tray_manager/windows/tray_native_api.cpp b/third_party/tray_manager/windows/tray_native_api.cpp new file mode 100644 index 00000000..6270686e --- /dev/null +++ b/third_party/tray_manager/windows/tray_native_api.cpp @@ -0,0 +1,416 @@ +#include "tray_native_api.h" + +#include + +#include + +namespace tray_manager { + +UINT TrayCallbackEvent(LPARAM value) { + return LOWORD(static_cast(value)); +} + +UINT TrayCallbackIconId(LPARAM value) { + return HIWORD(static_cast(value)); +} + +namespace { + +TrayOperationResult Success() { + TrayOperationResult result{}; + result.succeeded = true; + return result; +} + +} // namespace + +HICON WindowsTrayNativeApi::LoadIconFile(const wchar_t* path, + int width, + int height) { + return static_cast(::LoadImageW(nullptr, path, IMAGE_ICON, width, + height, LR_LOADFROMFILE)); +} + +bool WindowsTrayNativeApi::DestroyIconHandle(HICON icon) { + return ::DestroyIcon(icon) != FALSE; +} + +bool WindowsTrayNativeApi::NotifyIcon(DWORD message, + NOTIFYICONDATAW* data) { + return ::Shell_NotifyIconW(message, data) != FALSE; +} + +HRESULT WindowsTrayNativeApi::NotifyIconGetRect( + const NOTIFYICONIDENTIFIER* identifier, + RECT* rect) { + return ::Shell_NotifyIconGetRect(identifier, rect); +} + +HMENU WindowsTrayNativeApi::CreatePopupMenuHandle() { + return ::CreatePopupMenu(); +} + +bool WindowsTrayNativeApi::AppendMenuItem(HMENU menu, + UINT flags, + UINT_PTR id, + const wchar_t* label) { + return ::AppendMenuW(menu, flags, id, label) != FALSE; +} + +bool WindowsTrayNativeApi::DestroyMenuHandle(HMENU menu) { + return ::DestroyMenu(menu) != FALSE; +} + +bool WindowsTrayNativeApi::GetCursorPosition(POINT* point) { + return ::GetCursorPos(point) != FALSE; +} + +bool WindowsTrayNativeApi::SetForegroundWindowHandle(HWND window) { + return ::SetForegroundWindow(window) != FALSE; +} + +bool WindowsTrayNativeApi::TrackMenu(HMENU menu, + int x, + int y, + HWND window) { + ::SetLastError(ERROR_SUCCESS); + const BOOL result = ::TrackPopupMenu(menu, TPM_BOTTOMALIGN | TPM_LEFTALIGN, + x, y, 0, window, nullptr); + // TrackPopupMenu also returns zero when the user dismisses the menu. That is + // a successful interaction unless Windows set a concrete error. + return result != FALSE || ::GetLastError() == ERROR_SUCCESS; +} + +DWORD WindowsTrayNativeApi::LastError() const { + return ::GetLastError(); +} + +TrayMenuController::~TrayMenuController() { + if (menu_ != nullptr) { + api_.DestroyMenuHandle(menu_); + menu_ = nullptr; + } +} + +bool TrayMenuController::Build(HMENU menu, + const std::vector& items, + std::string* error) { + for (const TrayMenuItem& item : items) { + UINT flags = item.type == TrayMenuItemType::kSeparator ? MF_SEPARATOR + : MF_STRING; + if (item.disabled) flags |= MF_GRAYED; + if (item.type == TrayMenuItemType::kCheckbox) { + flags |= item.checked ? MF_CHECKED : MF_UNCHECKED; + } + UINT_PTR item_id = static_cast(item.id); + HMENU unowned_submenu = nullptr; + if (item.type == TrayMenuItemType::kSubmenu) { + unowned_submenu = api_.CreatePopupMenuHandle(); + if (unowned_submenu == nullptr) { + *error = "tray-submenu-create-failed"; + return false; + } + if (!Build(unowned_submenu, item.children, error)) { + if (!api_.DestroyMenuHandle(unowned_submenu)) { + *error = "tray-submenu-destroy-failed"; + } + return false; + } + flags |= MF_POPUP; + item_id = reinterpret_cast(unowned_submenu); + } + if (!api_.AppendMenuItem(menu, flags, item_id, item.label.c_str())) { + if (unowned_submenu != nullptr) { + if (!api_.DestroyMenuHandle(unowned_submenu)) { + *error = "tray-submenu-destroy-failed"; + return false; + } + } + *error = "tray-menu-insert-failed"; + return false; + } + // After AppendMenu succeeds Windows transfers submenu ownership to root. + unowned_submenu = nullptr; + } + return true; +} + +TrayOperationResult TrayMenuController::SetItems( + const std::vector& items) { + HMENU replacement = api_.CreatePopupMenuHandle(); + if (replacement == nullptr) { + return {false, "tray-menu-create-failed", + "Windows could not create the BusyMax tray menu."}; + } + std::string error; + if (!Build(replacement, items, &error)) { + if (!api_.DestroyMenuHandle(replacement)) { + error = "tray-menu-destroy-failed"; + } + return {false, error, "Windows could not build the BusyMax tray menu."}; + } + HMENU previous = menu_; + menu_ = replacement; + if (previous != nullptr && !api_.DestroyMenuHandle(previous)) { + return {false, "tray-menu-destroy-failed", + "Windows could not release the previous BusyMax tray menu."}; + } + return Success(); +} + +TrayOperationResult TrayMenuController::Show(HWND window, + bool bring_to_front) { + if (menu_ == nullptr || window == nullptr) { + return {false, "tray-menu-unavailable", + "The BusyMax tray menu is unavailable."}; + } + POINT cursor{}; + if (!api_.GetCursorPosition(&cursor)) { + return {false, "tray-cursor-query-failed", + "Windows could not locate the pointer for the tray menu."}; + } + if (bring_to_front && !api_.SetForegroundWindowHandle(window)) { + return {false, "tray-window-foreground-failed", + "Windows could not foreground BusyMax for its tray menu."}; + } + if (!api_.TrackMenu(menu_, cursor.x, cursor.y, window)) { + return {false, "tray-menu-display-failed", + "Windows could not display the BusyMax tray menu."}; + } + return Success(); +} + +TrayIconController::TrayIconController(TrayNativeApi& api) : api_(api) { + notification_data_.cbSize = sizeof(notification_data_); + identifier_.cbSize = sizeof(identifier_); +} + +TrayIconController::~TrayIconController() { + if (available_) { + api_.NotifyIcon(NIM_DELETE, ¬ification_data_); + available_ = false; + } + ReleaseIcon(); +} + +TrayOperationResult TrayIconController::Failure(const char* code, + const char* message) const { + TrayOperationResult result{}; + result.error_code = code; + result.error_message = message; + return result; +} + +bool TrayIconController::FillNotificationData(HWND window, HICON icon) { + notification_data_ = {}; + notification_data_.cbSize = sizeof(notification_data_); + notification_data_.hWnd = window; + notification_data_.uID = 1; + notification_data_.uCallbackMessage = WM_USER + 1; + notification_data_.uFlags = NIF_MESSAGE | NIF_ICON; + notification_data_.hIcon = icon; + if (!tooltip_.empty()) { + notification_data_.uFlags |= NIF_TIP | NIF_SHOWTIP; + if (FAILED(StringCchCopyW(notification_data_.szTip, + _countof(notification_data_.szTip), + tooltip_.c_str()))) { + notification_data_.szTip[0] = L'\0'; + return false; + } + } + return true; +} + +void TrayIconController::FillIdentifier() { + identifier_ = {}; + identifier_.cbSize = sizeof(identifier_); + identifier_.hWnd = notification_data_.hWnd; + identifier_.uID = notification_data_.uID; + identifier_.guidItem = GUID_NULL; +} + +bool TrayIconController::ReleaseIcon() { + if (icon_ == nullptr) { + return true; + } + const bool destroyed = api_.DestroyIconHandle(icon_); + icon_ = nullptr; + notification_data_.hIcon = nullptr; + return destroyed; +} + +TrayOperationResult TrayIconController::Install(HWND window, + HICON icon, + bool own_new_icon) { + if (!FillNotificationData(window, icon)) { + if (own_new_icon && icon != nullptr && !api_.DestroyIconHandle(icon)) { + return Failure("tray-icon-destroy-failed", + "Windows could not release the invalid tray icon."); + } + return Failure("tray-tooltip-copy-failed", + "Windows could not prepare the BusyMax tray tooltip."); + } + if (!api_.NotifyIcon(NIM_ADD, ¬ification_data_)) { + available_ = false; + if (own_new_icon && icon != nullptr) { + if (!api_.DestroyIconHandle(icon)) { + return Failure("tray-add-rollback-failed", + "Windows could not add or release the tray icon."); + } + } + notification_data_.hIcon = icon_; + return Failure("tray-add-failed", + "Windows could not add the BusyMax notification icon."); + } + + notification_data_.uVersion = NOTIFYICON_VERSION_4; + if (!api_.NotifyIcon(NIM_SETVERSION, ¬ification_data_)) { + const bool deleted = api_.NotifyIcon(NIM_DELETE, ¬ification_data_); + available_ = false; + if (own_new_icon && icon != nullptr) { + if (!api_.DestroyIconHandle(icon)) { + return Failure("tray-version-icon-rollback-failed", + "Windows could not release the rejected tray icon."); + } + } + notification_data_.hIcon = icon_; + return Failure(deleted ? "tray-version-failed" + : "tray-version-rollback-failed", + "Windows rejected the notification icon protocol version."); + } + + HICON previous = icon_; + icon_ = icon; + available_ = true; + FillIdentifier(); + if (own_new_icon && previous != nullptr && previous != icon_ && + !api_.DestroyIconHandle(previous)) { + return Failure("tray-previous-icon-destroy-failed", + "Windows could not release the previous tray icon."); + } + return Success(); +} + +TrayOperationResult TrayIconController::SetIcon(HWND window, + const std::wstring& path) { + if (window == nullptr || path.empty()) { + return Failure("tray-icon-arguments-invalid", + "The tray icon path or window was invalid."); + } + HICON replacement = api_.LoadIconFile( + path.c_str(), GetSystemMetrics(SM_CXSMICON), + GetSystemMetrics(SM_CYSMICON)); + if (replacement == nullptr) { + return Failure("tray-icon-load-failed", + "Windows could not load the BusyMax tray icon."); + } + + if (!available_) { + return Install(window, replacement, true); + } + + NOTIFYICONDATAW modified = notification_data_; + modified.uFlags = NIF_MESSAGE | NIF_ICON; + modified.hIcon = replacement; + if (!tooltip_.empty()) { + modified.uFlags |= NIF_TIP | NIF_SHOWTIP; + } + if (!api_.NotifyIcon(NIM_MODIFY, &modified)) { + if (!api_.DestroyIconHandle(replacement)) { + return Failure("tray-modify-rollback-failed", + "Windows could not replace or release the tray icon."); + } + return Failure("tray-modify-failed", + "Windows could not replace the BusyMax tray icon."); + } + + HICON previous = icon_; + icon_ = replacement; + notification_data_ = modified; + if (previous != nullptr && previous != replacement) { + if (!api_.DestroyIconHandle(previous)) { + return Failure("tray-previous-icon-destroy-failed", + "Windows could not release the previous tray icon."); + } + } + return Success(); +} + +TrayOperationResult TrayIconController::SetTooltip( + const std::wstring& tooltip) { + if (tooltip.size() >= _countof(notification_data_.szTip)) { + return Failure("tray-tooltip-too-long", + "The BusyMax tray tooltip exceeded the Windows limit."); + } + if (!available_) { + tooltip_ = tooltip; + return Success(); + } + + NOTIFYICONDATAW modified = notification_data_; + modified.uFlags = NIF_MESSAGE | NIF_ICON; + modified.szTip[0] = L'\0'; + if (!tooltip.empty()) { + modified.uFlags |= NIF_TIP | NIF_SHOWTIP; + const HRESULT copied = StringCchCopyW( + modified.szTip, _countof(modified.szTip), tooltip.c_str()); + if (FAILED(copied)) { + return Failure("tray-tooltip-copy-failed", + "Windows could not prepare the BusyMax tray tooltip."); + } + } + if (!api_.NotifyIcon(NIM_MODIFY, &modified)) { + return Failure("tray-tooltip-modify-failed", + "Windows could not update the BusyMax tray tooltip."); + } + tooltip_ = tooltip; + notification_data_ = modified; + return Success(); +} + +TrayOperationResult TrayIconController::Recover(HWND window) { + if (icon_ == nullptr || window == nullptr) { + available_ = false; + return Failure("tray-recovery-unavailable", + "No valid tray icon was available for recovery."); + } + available_ = false; + return Install(window, icon_, false); +} + +TrayOperationResult TrayIconController::Destroy() { + bool deleted = true; + if (available_) { + deleted = api_.NotifyIcon(NIM_DELETE, ¬ification_data_); + } + available_ = false; + const bool icon_destroyed = ReleaseIcon(); + notification_data_ = {}; + notification_data_.cbSize = sizeof(notification_data_); + identifier_ = {}; + identifier_.cbSize = sizeof(identifier_); + if (!deleted) { + return Failure("tray-delete-failed", + "Windows could not remove the BusyMax tray icon."); + } + if (!icon_destroyed) { + return Failure("tray-icon-destroy-failed", + "Windows could not release the BusyMax tray icon."); + } + return Success(); +} + +TrayOperationResult TrayIconController::GetBounds(RECT* rect) { + if (!available_ || rect == nullptr) { + return Failure("tray-bounds-unavailable", + "The BusyMax tray icon is not available."); + } + *rect = {}; + if (FAILED(api_.NotifyIconGetRect(&identifier_, rect))) { + return Failure("tray-bounds-query-failed", + "Windows could not locate the BusyMax tray icon."); + } + return Success(); +} + +} // namespace tray_manager diff --git a/third_party/tray_manager/windows/tray_native_api.h b/third_party/tray_manager/windows/tray_native_api.h new file mode 100644 index 00000000..d6069447 --- /dev/null +++ b/third_party/tray_manager/windows/tray_native_api.h @@ -0,0 +1,124 @@ +#ifndef TRAY_MANAGER_TRAY_NATIVE_API_H_ +#define TRAY_MANAGER_TRAY_NATIVE_API_H_ + +#include + +#include + +#include +#include + +namespace tray_manager { + +UINT TrayCallbackEvent(LPARAM value); +UINT TrayCallbackIconId(LPARAM value); + +struct TrayOperationResult { + bool succeeded = false; + std::string error_code; + std::string error_message; +}; + +class TrayNativeApi { + public: + virtual ~TrayNativeApi() = default; + + virtual HICON LoadIconFile(const wchar_t* path, int width, int height) = 0; + virtual bool DestroyIconHandle(HICON icon) = 0; + virtual bool NotifyIcon(DWORD message, NOTIFYICONDATAW* data) = 0; + virtual HRESULT NotifyIconGetRect(const NOTIFYICONIDENTIFIER* identifier, + RECT* rect) = 0; + virtual HMENU CreatePopupMenuHandle() = 0; + virtual bool AppendMenuItem(HMENU menu, + UINT flags, + UINT_PTR id, + const wchar_t* label) = 0; + virtual bool DestroyMenuHandle(HMENU menu) = 0; + virtual bool GetCursorPosition(POINT* point) = 0; + virtual bool SetForegroundWindowHandle(HWND window) = 0; + virtual bool TrackMenu(HMENU menu, int x, int y, HWND window) = 0; + virtual DWORD LastError() const = 0; +}; + +class WindowsTrayNativeApi final : public TrayNativeApi { + public: + HICON LoadIconFile(const wchar_t* path, int width, int height) override; + bool DestroyIconHandle(HICON icon) override; + bool NotifyIcon(DWORD message, NOTIFYICONDATAW* data) override; + HRESULT NotifyIconGetRect(const NOTIFYICONIDENTIFIER* identifier, + RECT* rect) override; + HMENU CreatePopupMenuHandle() override; + bool AppendMenuItem(HMENU menu, + UINT flags, + UINT_PTR id, + const wchar_t* label) override; + bool DestroyMenuHandle(HMENU menu) override; + bool GetCursorPosition(POINT* point) override; + bool SetForegroundWindowHandle(HWND window) override; + bool TrackMenu(HMENU menu, int x, int y, HWND window) override; + DWORD LastError() const override; +}; + +enum class TrayMenuItemType { kNormal, kSeparator, kCheckbox, kSubmenu }; + +struct TrayMenuItem { + int id = 0; + TrayMenuItemType type = TrayMenuItemType::kNormal; + std::wstring label; + bool disabled = false; + bool checked = false; + std::vector children; +}; + +class TrayMenuController { + public: + explicit TrayMenuController(TrayNativeApi& api) : api_(api) {} + ~TrayMenuController(); + + TrayOperationResult SetItems(const std::vector& items); + TrayOperationResult Show(HWND window, bool bring_to_front); + bool available() const { return menu_ != nullptr; } + + private: + bool Build(HMENU menu, const std::vector& items, + std::string* error); + + TrayNativeApi& api_; + HMENU menu_ = nullptr; +}; + +class TrayIconController { + public: + explicit TrayIconController(TrayNativeApi& api); + ~TrayIconController(); + + TrayIconController(const TrayIconController&) = delete; + TrayIconController& operator=(const TrayIconController&) = delete; + + bool available() const { return available_; } + const std::wstring& tooltip() const { return tooltip_; } + + TrayOperationResult SetIcon(HWND window, const std::wstring& path); + TrayOperationResult SetTooltip(const std::wstring& tooltip); + TrayOperationResult Recover(HWND window); + TrayOperationResult Destroy(); + TrayOperationResult GetBounds(RECT* rect); + + private: + TrayOperationResult Install(HWND window, HICON icon, bool own_new_icon); + TrayOperationResult Failure(const char* code, const char* message) const; + bool FillNotificationData(HWND window, HICON icon); + void FillIdentifier(); + bool ReleaseIcon(); + + TrayNativeApi& api_; + NOTIFYICONDATAW notification_data_{}; + NOTIFYICONIDENTIFIER identifier_{}; + HICON icon_ = nullptr; + std::wstring tooltip_; + bool available_ = false; +}; + +} // namespace tray_manager + +#endif // TRAY_MANAGER_TRAY_NATIVE_API_H_ diff --git a/third_party/yaru_window/LICENSE b/third_party/yaru_window/LICENSE new file mode 100644 index 00000000..a612ad98 --- /dev/null +++ b/third_party/yaru_window/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/third_party/yaru_window/README.busymax.md b/third_party/yaru_window/README.busymax.md new file mode 100644 index 00000000..751f8484 --- /dev/null +++ b/third_party/yaru_window/README.busymax.md @@ -0,0 +1,7 @@ +This is the public Dart API from upstream `yaru_window` 0.2.2, licensed under +the included LGPL-3.0 license. BusyMax narrows the plugin declaration to Linux. + +The upstream package selects a `window_manager` implementation on Windows. +BusyMax has a separate native Windows runner bridge, so registering that second +window lifecycle owner would violate the Windows architecture. Linux continues +to use the upstream `yaru_window_linux` implementation unchanged. diff --git a/third_party/yaru_window/lib/src/window.dart b/third_party/yaru_window/lib/src/window.dart new file mode 100644 index 00000000..cde4c238 --- /dev/null +++ b/third_party/yaru_window/lib/src/window.dart @@ -0,0 +1,223 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:yaru_window_platform_interface/yaru_window_platform_interface.dart'; + +/// Provides access to the closest top-level window instance that encloses the +/// given context. +class YaruWindow { + static final Map _windows = {}; + + static YaruWindowInstance _instance([int id = 0]) { + return _windows[id] ??= YaruWindowInstance._(id); + } + + /// Returns the closest top-level window instance that encloses the given + /// context. + static YaruWindowInstance of(BuildContext context) { + const id = 0; // View.of(context).windowId; + // ignore: avoid_redundant_argument_values + return _instance(id); + } + + /// Closes the window. + static Future close(BuildContext context) { + return YaruWindow.of(context).close(); + } + + /// Starts dragging the window. + static Future drag(BuildContext context) { + return YaruWindow.of(context).drag(); + } + + /// Enters fullscreen mode. + static Future fullscreen(BuildContext context) { + return YaruWindow.of(context).fullscreen(); + } + + /// Hides the window. + static Future hide(BuildContext context) { + return YaruWindow.of(context).hide(); + } + + /// Hides the title bar. + static Future hideTitle(BuildContext context) { + return YaruWindow.of(context).hideTitle(); + } + + /// Maximizes the window. + static Future maximize(BuildContext context) { + return YaruWindow.of(context).maximize(); + } + + /// Minimizes the window. + static Future minimize(BuildContext context) { + return YaruWindow.of(context).minimize(); + } + + /// Restores the window to its normal state. + static Future restore(BuildContext context) { + return YaruWindow.of(context).restore(); + } + + /// Shows the window. + static Future show(BuildContext context) { + return YaruWindow.of(context).show(); + } + + /// Shows the window menu. + static Future showMenu(BuildContext context) { + return YaruWindow.of(context).showMenu(); + } + + /// Shows the title bar. + static Future showTitle(BuildContext context) { + return YaruWindow.of(context).showTitle(); + } + + /// Sets the background color of the window. + static Future setBackground(BuildContext context, Color color) { + return YaruWindow.of(context).setBackground(color); + } + + /// Sets the brightness of the window. + static Future setBrightness( + BuildContext context, + Brightness brightness, + ) { + return YaruWindow.of(context).setBrightness(brightness); + } + + /// Sets the title of the window. + static Future setTitle(BuildContext context, String title) { + return YaruWindow.of(context).setTitle(title); + } + + /// Sets whether the window can be minimized. + static Future setMinimizable(BuildContext context, bool minimizable) { + return YaruWindow.of(context).setMinimizable(minimizable); + } + + /// Sets whether the window can be maximized. + static Future setMaximizable(BuildContext context, bool maximizable) { + return YaruWindow.of(context).setMaximizable(maximizable); + } + + /// Sets whether the window can be closed. + static Future setClosable(BuildContext context, bool closable) { + return YaruWindow.of(context).setClosable(closable); + } + + /// Returns the current state of the window. + static Future state(BuildContext context) { + return YaruWindow.of(context).state(); + } + + /// Returns a stream of window state changes. + static Stream states(BuildContext context) { + return YaruWindow.of(context).states(); + } + + /// Installs a close handler for the window. + static Future onClose( + BuildContext context, + FutureOr Function() handler, + ) { + return YaruWindow.of(context).onClose(handler); + } + + /// Ensures that the window is initialized and returns the primary instance. + static Future ensureInitialized() async { + WidgetsFlutterBinding.ensureInitialized(); + final window = YaruWindow._instance(); + await window.init(); + return window; + } +} + +/// A top-level window instance. +class YaruWindowInstance { + YaruWindowInstance._(this._id); + + final int _id; // ignore: unused_field + + YaruWindowPlatform get _platform => YaruWindowPlatform.instance; + + /// Initializes the window. + Future init() => _platform.init(_id); + + /// Closes the window. + Future close() => _platform.close(_id); + + /// Starts dragging the window. + Future drag() => _platform.drag(_id); + + /// Enters fullscreen mode. + Future fullscreen() => _platform.fullscreen(_id); + + /// Hides the window. + Future hide() => _platform.hide(_id); + + /// Hides the title bar. + Future hideTitle() => _platform.hideTitle(_id); + + /// Maximizes the window. + Future maximize() => _platform.maximize(_id); + + /// Minimizes the window. + Future minimize() => _platform.minimize(_id); + + /// Restores the window to its normal state. + Future restore() => _platform.restore(_id); + + /// Shows the window. + Future show() => _platform.show(_id); + + /// Shows the window menu. + Future showMenu() => _platform.showMenu(_id); + + /// Shows the title bar. + Future showTitle() => _platform.showTitle(_id); + + /// Sets the background color of the window. + Future setBackground(Color color) { + return _platform.setBackground(_id, color); + } + + /// Sets the brightness of the window. + Future setBrightness(Brightness brightness) { + return _platform.setBrightness(_id, brightness); + } + + /// Sets the title of the window. + Future setTitle(String title) => _platform.setTitle(_id, title); + + /// Sets whether the window can be minimized. + Future setMinimizable(bool minimizable) { + return _platform.setMinimizable(_id, minimizable); + } + + /// Sets whether the window can be maximized. + Future setMaximizable(bool maximizable) { + return _platform.setMaximizable(_id, maximizable); + } + + /// Sets whether the window can be closed. + Future setClosable(bool closable) { + return _platform.setClosable(_id, closable); + } + + /// Returns the current state of the window. + Future state() => _platform.state(_id); + + /// Returns a stream of window state changes. + Stream states() async* { + yield await _platform.state(_id); + yield* _platform.states(_id); + } + + /// Installs a close handler for the window. + Future onClose(FutureOr Function() handler) { + return _platform.onClose(_id, handler); + } +} diff --git a/third_party/yaru_window/lib/yaru_window.dart b/third_party/yaru_window/lib/yaru_window.dart new file mode 100644 index 00000000..e4e50651 --- /dev/null +++ b/third_party/yaru_window/lib/yaru_window.dart @@ -0,0 +1,7 @@ +/// Provides API for interacting with top-level windows. +library; + +export 'package:yaru_window_platform_interface/yaru_window_platform_interface.dart' + show YaruWindowState; + +export 'src/window.dart'; diff --git a/third_party/yaru_window/pubspec.yaml b/third_party/yaru_window/pubspec.yaml new file mode 100644 index 00000000..60a82719 --- /dev/null +++ b/third_party/yaru_window/pubspec.yaml @@ -0,0 +1,21 @@ +name: yaru_window +description: Linux-only BusyMax packaging of the upstream yaru_window API. +repository: https://github.com/ubuntu/yaru_window.dart +version: 0.2.2 +publish_to: none + +environment: + sdk: ">=3.1.0 <4.0.0" + flutter: ">=3.32.0" + +dependencies: + flutter: + sdk: flutter + yaru_window_linux: 0.2.2 + yaru_window_platform_interface: 0.1.3 + +flutter: + plugin: + platforms: + linux: + default_package: yaru_window_linux diff --git a/tools/README.md b/tool/README.md similarity index 100% rename from tools/README.md rename to tool/README.md diff --git a/tools/build_install_snap_local.sh b/tool/build_install_snap_local.sh similarity index 96% rename from tools/build_install_snap_local.sh rename to tool/build_install_snap_local.sh index 9b42d6c8..27dd206a 100755 --- a/tools/build_install_snap_local.sh +++ b/tool/build_install_snap_local.sh @@ -3,7 +3,7 @@ set -euo pipefail usage() { cat <<'EOF' -Usage: tools/build_install_snap_local.sh [version] [options] +Usage: tool/build_install_snap_local.sh [version] [options] Build the Flutter Linux release, replace the payload in an installed snap scaffold, pack it, install it locally, and optionally run it. @@ -285,7 +285,17 @@ echo "App ID: $APP_ID" echo "Scaffold: $SNAP_SCAFFOLD" echo "Root: $SNAP_ROOT" echo "Output: $OUT" -echo "Defines: $((${#DART_DEFINE_ARGS[@]} + ${#DART_DEFINE_FILE_ARGS[@]})) build-time entries" +DEFINE_ENTRY_COUNT=$((${#DART_DEFINE_ARGS[@]} + ${#DART_DEFINE_FILE_ARGS[@]})) +echo "Defines: $DEFINE_ENTRY_COUNT build-time entries" +if [[ "$DEFINE_ENTRY_COUNT" -eq 0 ]]; then + cat >&2 <<'EOF' +Warning: No OAuth build definitions were supplied. +Google and Microsoft sign-in will be unavailable in this local build. +Apple and Nextcloud remain available. +To configure OAuth providers, rebuild with: + ./tool/build_install_snap_local.sh --dart-define-from-file .snap-local/busymax-dart-defines.json +EOF +fi if [[ "$SKIP_TESTS" != "1" ]]; then echo "== Validate source ==" diff --git a/tool/check_platform_boundaries.dart b/tool/check_platform_boundaries.dart new file mode 100644 index 00000000..763ed7d3 --- /dev/null +++ b/tool/check_platform_boundaries.dart @@ -0,0 +1,169 @@ +import 'dart:io'; + +const _linuxOnlyPackages = [ + 'package:yaru/', + 'package:ubuntu_localizations/', + 'package:dbus/', + 'package:desktop_notifications/', + 'package:xdg_status_notifier_item/', + 'package:posix/', +]; + +const _fluentPackages = [ + 'package:fluent_ui/', + 'package:fluentui_system_icons/', +]; + +final _importPattern = RegExp( + r'''^\s*(?:import|export)\s+['"]([^'"]+)['"]''', + multiLine: true, +); + +void main() { + final root = Directory.current; + final failures = findPlatformBoundaryViolations(root); + if (failures.isNotEmpty) { + stderr.writeln('BusyMax platform boundary violations:'); + for (final failure in failures) { + stderr.writeln(' - $failure'); + } + exitCode = 1; + return; + } + stdout.writeln('BusyMax platform boundaries are valid.'); +} + +List findPlatformBoundaryViolations(Directory root) { + final lib = Directory('${root.path}/lib'); + if (!lib.existsSync()) { + return ['BusyMax lib directory not found at ${lib.path}.']; + } + final failures = []; + final dartFiles = lib + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.dart')); + for (final file in dartFiles) { + final relative = _relative(root.path, file.path); + final source = file.readAsStringSync(); + final isWindows = + relative == 'lib/main_windows.dart' || relative.contains('/windows/'); + final isCommonPlatform = + relative.contains('/app/common/') || + relative.contains('/ui/common/') || + relative.contains('/platform/common/'); + final isLinux = + relative == 'lib/main_linux.dart' || + relative.contains('/linux/') || + _isLegacyLinuxPresentation(relative); + final isSharedBusiness = + relative.startsWith('lib/src/') && + !isWindows && + !isLinux && + !relative.contains('/presentation/'); + + for (final match in _importPattern.allMatches(source)) { + final import = match.group(1)!; + if ((isWindows || isCommonPlatform) && + _linuxOnlyPackages.any(import.startsWith)) { + failures.add('$relative imports Linux-only $import'); + } + if ((isCommonPlatform || isSharedBusiness || isLinux) && + _fluentPackages.any(import.startsWith)) { + failures.add('$relative imports Windows-only $import'); + } + if (isWindows && _isLinuxSourceImport(import)) { + failures.add('$relative imports Linux source $import'); + } + } + } + + _checkWindowsReachability(root, failures); + _checkNativePluginIsolation(root, failures); + failures.sort(); + return failures; +} + +void _checkNativePluginIsolation(Directory root, List failures) { + final linuxPlugins = File( + '${root.path}/linux/flutter/generated_plugins.cmake', + ); + if (linuxPlugins.existsSync()) { + final source = linuxPlugins.readAsStringSync(); + if (source.contains('tray_manager')) { + failures.add( + 'Linux plugin graph registers tray_manager instead of the existing ' + 'DBus/XDG tray adapter.', + ); + } + if (source.contains('flutter_timezone')) { + failures.add( + 'Linux plugin graph registers flutter_timezone instead of the ' + 'existing Linux timezone source.', + ); + } + } + final windowsPlugins = File( + '${root.path}/windows/flutter/generated_plugins.cmake', + ); + if (!windowsPlugins.existsSync()) { + failures.add('Windows generated plugin graph is missing.'); + return; + } + final windowsSource = windowsPlugins.readAsStringSync(); + for (final plugin in const ['tray_manager', 'flutter_timezone']) { + if (!windowsSource.contains(plugin)) { + failures.add('Windows plugin graph does not register $plugin.'); + } + } +} + +void _checkWindowsReachability(Directory root, List failures) { + final entrypoint = File('${root.path}/lib/main_windows.dart'); + final queue = [entrypoint]; + final visited = {}; + while (queue.isNotEmpty) { + final file = queue.removeLast(); + if (!visited.add(file.path) || !file.existsSync()) continue; + final relative = _relative(root.path, file.path); + for (final match in _importPattern.allMatches(file.readAsStringSync())) { + final import = match.group(1)!; + if (_linuxOnlyPackages.any(import.startsWith) || + _isLinuxSourceImport(import)) { + failures.add('Windows graph: $relative reaches $import'); + } + final resolved = _resolveLocalImport(root, file, import); + if (resolved != null) queue.add(resolved); + } + } +} + +File? _resolveLocalImport(Directory root, File source, String import) { + if (import.startsWith('package:busymax/')) { + return File('${root.path}/${import.substring('package:busymax/'.length)}'); + } + if (import.startsWith('dart:') || import.startsWith('package:')) return null; + return File(Uri.file(source.path).resolve(import).toFilePath()); +} + +bool _isLinuxSourceImport(String import) { + final normalized = import.replaceAll('\\', '/').toLowerCase(); + return normalized.contains('/linux/') || + normalized.contains('linux_') || + normalized.contains('/gtk_') || + normalized.contains('gtk_'); +} + +bool _isLegacyLinuxPresentation(String relative) { + // Existing Yaru feature widgets remain the Linux presentation while the + // Windows entrypoint uses lib/src/ui/windows exclusively. + return relative.contains('/presentation/') && + !relative.contains('/ui/windows/'); +} + +String _relative(String root, String path) { + final prefix = root.endsWith(Platform.pathSeparator) + ? root + : '$root${Platform.pathSeparator}'; + return path.startsWith(prefix) ? path.substring(prefix.length) : path; +} diff --git a/tool/generate_windows_assets.dart b/tool/generate_windows_assets.dart new file mode 100644 index 00000000..f633ac77 --- /dev/null +++ b/tool/generate_windows_assets.dart @@ -0,0 +1,162 @@ +import 'dart:io'; + +import 'package:image/image.dart' as image; + +void main() { + final sourceFile = File('assets/branding/busymax-logo.png'); + final source = image.decodePng(sourceFile.readAsBytesSync()); + if (source == null || source.width < 512 || source.height < 512) { + stderr.writeln('BusyMax source logo must be a 512x512 or larger PNG.'); + exitCode = 1; + return; + } + + final runnerResources = Directory('windows/runner/resources') + ..createSync(recursive: true); + final packageAssets = Directory('${runnerResources.path}/msix') + ..createSync(recursive: true); + final flutterAssets = Directory('assets/windows') + ..createSync(recursive: true); + final iconSource = _withTransparentPadding(source, fractionPerSide: 0.06); + final packageSource = _withTransparentPadding(source, fractionPerSide: 0.10); + + final ico = _encodeIco(iconSource); + File('${runnerResources.path}/app_icon.ico').writeAsBytesSync(ico); + File('${flutterAssets.path}/busymax_tray.ico').writeAsBytesSync(ico); + File( + '${flutterAssets.path}/busymax_tray_offline.ico', + ).writeAsBytesSync(_encodeIco(_offlineVariant(iconSource))); + + _writeScaleSet(packageAssets, packageSource, 'Square44x44Logo', const { + 100: 44, + 125: 55, + 150: 66, + 200: 88, + 400: 176, + }); + _writePng(packageAssets, packageSource, 'Square44x44Logo.png', 44); + _writeScaleSet(packageAssets, packageSource, 'Square150x150Logo', const { + 100: 150, + 125: 188, + 150: 225, + 200: 300, + 400: 600, + }); + _writePng(packageAssets, packageSource, 'Square150x150Logo.png', 150); + _writeScaleSet(packageAssets, packageSource, 'StoreLogo', const { + 100: 50, + 125: 63, + 150: 75, + 200: 100, + 400: 200, + }); + _writePng(packageAssets, packageSource, 'StoreLogo.png', 50); + _writePng(packageAssets, packageSource, 'FileAssociation.png', 44); + _writePng(packageAssets, packageSource, 'FileAssociationLogo.png', 44); + _writeScaleSet(packageAssets, packageSource, 'FileAssociation', const { + 100: 44, + 125: 55, + 150: 66, + 200: 88, + 400: 176, + }); + stdout.writeln('Generated BusyMax Windows assets from ${sourceFile.path}.'); +} + +void _writeScaleSet( + Directory directory, + image.Image source, + String name, + Map sizes, +) { + for (final MapEntry(key: scale, value: size) in sizes.entries) { + _writePng(directory, source, '$name.scale-$scale.png', size); + } +} + +void _writePng(Directory directory, image.Image source, String name, int size) { + File( + '${directory.path}/$name', + ).writeAsBytesSync(image.encodePng(_resize(source, size), level: 9)); +} + +image.Image _resize(image.Image source, int size) => image.copyResize( + source, + width: size, + height: size, + interpolation: image.Interpolation.cubic, +); + +image.Image _withTransparentPadding( + image.Image source, { + required double fractionPerSide, +}) { + final canvasSize = source.width > source.height + ? source.width + : source.height; + final contentSize = (canvasSize * (1 - 2 * fractionPerSide)).round(); + final content = image.copyResize( + source, + width: contentSize, + height: contentSize, + interpolation: image.Interpolation.cubic, + ); + final canvas = image.Image( + width: canvasSize, + height: canvasSize, + numChannels: 4, + ); + image.compositeImage( + canvas, + content, + dstX: (canvasSize - contentSize) ~/ 2, + dstY: (canvasSize - contentSize) ~/ 2, + ); + return canvas; +} + +List _encodeIco(image.Image source) { + final frames = [ + for (final size in const [256, 128, 64, 48, 40, 32, 24, 20, 16]) + _resize(source, size), + ]; + final icon = frames.first; + for (final frame in frames.skip(1)) { + icon.addFrame(frame); + } + return image.encodeIco(icon); +} + +image.Image _offlineVariant(image.Image source) { + final offline = image.grayscale(source.clone()); + final unit = offline.width / 512; + int scaled(double value) => (value * unit).round(); + final center = scaled(396); + image.fillCircle( + offline, + x: center, + y: center, + radius: scaled(56), + color: image.ColorRgb8(250, 250, 250), + antialias: true, + ); + image.fillCircle( + offline, + x: center, + y: center, + radius: scaled(47), + color: image.ColorRgb8(39, 39, 42), + antialias: true, + ); + image.drawLine( + offline, + x1: scaled(358), + y1: scaled(358), + x2: scaled(434), + y2: scaled(434), + color: image.ColorRgb8(250, 250, 250), + thickness: scaled(11), + antialias: true, + ); + return offline; +} diff --git a/tools/google_tasks_discovery/cache/tasks_v1_discovery.json b/tool/google_tasks_discovery/cache/tasks_v1_discovery.json similarity index 100% rename from tools/google_tasks_discovery/cache/tasks_v1_discovery.json rename to tool/google_tasks_discovery/cache/tasks_v1_discovery.json diff --git a/tools/google_tasks_discovery/fetch_tasks_discovery.dart b/tool/google_tasks_discovery/fetch_tasks_discovery.dart similarity index 96% rename from tools/google_tasks_discovery/fetch_tasks_discovery.dart rename to tool/google_tasks_discovery/fetch_tasks_discovery.dart index f0fb0120..04da3dc6 100644 --- a/tools/google_tasks_discovery/fetch_tasks_discovery.dart +++ b/tool/google_tasks_discovery/fetch_tasks_discovery.dart @@ -4,7 +4,7 @@ import 'dart:io'; const discoveryUrl = 'https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest'; const lockedRevision = '20260602'; -const cachePath = 'tools/google_tasks_discovery/cache/tasks_v1_discovery.json'; +const cachePath = 'tool/google_tasks_discovery/cache/tasks_v1_discovery.json'; const revisionDartPath = 'lib/src/google_tasks/api/tasks_discovery_revision.dart'; diff --git a/tools/install_linux_dev_desktop.sh b/tool/install_linux_dev_desktop.sh similarity index 97% rename from tools/install_linux_dev_desktop.sh rename to tool/install_linux_dev_desktop.sh index 25e01929..e3130283 100755 --- a/tools/install_linux_dev_desktop.sh +++ b/tool/install_linux_dev_desktop.sh @@ -3,7 +3,7 @@ set -euo pipefail usage() { cat <<'EOF' -Usage: tools/install_linux_dev_desktop.sh [options] +Usage: tool/install_linux_dev_desktop.sh [options] Register this BusyMax checkout with the current user's Linux desktop so GNOME can associate native Wayland windows with the BusyMax launcher and icon. @@ -110,7 +110,7 @@ GENERATED_DESKTOP="$TEMP_DIR/${APP_ID}.desktop" awk -v executable="$EXECUTABLE" -v icon="$ICON_DEST" ' /^Exec=/ { - print "Exec=\"" executable "\"" + print "Exec=\"" executable "\" %U" next } /^Icon=/ { diff --git a/tool/windows/AppxManifest.xml.template b/tool/windows/AppxManifest.xml.template new file mode 100644 index 00000000..910ad66f --- /dev/null +++ b/tool/windows/AppxManifest.xml.template @@ -0,0 +1,94 @@ + + + + + BusyMax + @@PUBLISHER_DISPLAY_NAME@@ + BusyMax calendar and task manager + Assets\StoreLogo.png + + + + + +@@RESOURCE_LANGUAGES@@ + + + + + + + + iCalendar file + Assets\FileAssociationLogo.png + + .ics + + + + + + Web calendar subscription + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tool/windows/build_release.ps1 b/tool/windows/build_release.ps1 new file mode 100644 index 00000000..d19be91c --- /dev/null +++ b/tool/windows/build_release.ps1 @@ -0,0 +1,141 @@ +param( + [Parameter(Mandatory)][string]$ConfigPath, + [switch]$Ci, + [ValidateSet( + 'All', 'PesterTests', 'SourceGeneration', 'StaticAnalysis', + 'FlutterTests', 'WindowsCompile', 'NativeTests', 'Package')] + [string]$Stage = 'All' +) + +. "$PSScriptRoot/common.ps1" +& "$PSScriptRoot/check_prerequisites.ps1" | Out-Host +$identity = & "$PSScriptRoot/validate_release_config.ps1" ` + -ConfigPath $ConfigPath -Ci:$Ci +$config = Get-BusyMaxStoreConfig -Path $ConfigPath + +function Invoke-BusyMaxPesterTests { + $pesterModule = Get-Module -ListAvailable Pester | + Sort-Object Version -Descending | Select-Object -First 1 + if ($null -eq $pesterModule -or $pesterModule.Version -lt [version]'5.0.0') { + throw 'Pester 5 is required to run the Windows packaging contract tests.' + } + Import-Module Pester -MinimumVersion 5.0.0 + New-Item -ItemType Directory -Force ` + -Path 'build\windows\test-results' | Out-Null + $pester = Invoke-Pester ` + -Path 'tool\windows\tests' -PassThru -Output Detailed + [pscustomobject]@{ + Result = [string]$pester.Result + PassedCount = $pester.PassedCount + FailedCount = $pester.FailedCount + SkippedCount = $pester.SkippedCount + } | ConvertTo-Json | Set-Content ` + -LiteralPath 'build\windows\test-results\pester-summary.json' ` + -Encoding utf8NoBOM + if ($pester.FailedCount -ne 0) { + throw 'Windows packaging contract tests failed.' + } +} + +function Invoke-BusyMaxSourceGeneration { + & flutter pub get + if ($LASTEXITCODE -ne 0) { throw 'Dependency resolution failed.' } + & flutter gen-l10n + if ($LASTEXITCODE -ne 0) { throw 'Localization generation failed.' } + & dart run build_runner build --delete-conflicting-outputs --force-jit + if ($LASTEXITCODE -ne 0) { throw 'Code generation failed.' } + & git diff --exit-code -- lib/l10n/generated lib/src/db + if ($LASTEXITCODE -ne 0) { throw 'Generated files are not committed.' } +} + +function Invoke-BusyMaxStaticAnalysis { + & dart format --output=none --set-exit-if-changed . + if ($LASTEXITCODE -ne 0) { throw 'Formatting check failed.' } + & flutter analyze + if ($LASTEXITCODE -ne 0) { throw 'Flutter analysis failed.' } + & dart run tool/check_platform_boundaries.dart + if ($LASTEXITCODE -ne 0) { throw 'Platform boundary validation failed.' } +} + +function Invoke-BusyMaxFlutterTests { + New-Item -ItemType Directory -Force ` + -Path 'build\windows\test-results' | Out-Null + & flutter test --machine | Tee-Object ` + -FilePath 'build\windows\test-results\flutter-tests.jsonl' + if ($LASTEXITCODE -ne 0) { throw 'Flutter tests failed.' } +} + +function Invoke-BusyMaxWindowsCompile { + $defines = @( + '--dart-define=BUSYMAX_WINDOWS_STORE_MODE=true', + "--dart-define=BUSYMAX_WINDOWS_AUMID=$($identity.AppUserModelId)", + "--dart-define=BUSYMAX_MSIX_VERSION=$($config.msixVersion)", + "--dart-define=BUSYMAX_PRIVACY_POLICY_URL=$($config.privacyPolicyUrl)", + "--dart-define=BUSYMAX_SUPPORT_URL=$($config.supportUrl)", + "--dart-define=BUSYMAX_HOMEPAGE_URL=$($config.homepageUrl)", + "--dart-define=GOOGLE_OAUTH_CLIENT_ID=$($config.googleOAuthClientId)", + "--dart-define=MICROSOFT_OAUTH_CLIENT_ID=$($config.microsoftOAuthClientId)", + "--dart-define=MICROSOFT_OAUTH_AUTHORITY_TENANT=$($config.microsoftOAuthAuthorityTenant)", + '--dart-define=BUSYMAX_FAKE_DATA=false' + ) + if (-not [string]::IsNullOrWhiteSpace($config.googleOAuthClientSecret)) { + $defines += "--dart-define=GOOGLE_OAUTH_CLIENT_SECRET=$($config.googleOAuthClientSecret)" + } + New-Item -ItemType Directory -Force ` + -Path 'build\windows\test-results' | Out-Null + & flutter build windows --release -t lib/main_windows.dart @defines 2>&1 | + Tee-Object -FilePath 'build\windows\test-results\windows-build.log' + $compileExitCode = $LASTEXITCODE + if ($compileExitCode -ne 0) { + throw "Flutter Windows release build failed with exit code $compileExitCode." + } +} + +function Invoke-BusyMaxNativeTests { + & "$PSScriptRoot/run_native_tests.ps1" + if ($LASTEXITCODE -ne 0) { throw 'Windows native tests failed.' } +} + +function Invoke-BusyMaxPackage { + New-Item -ItemType Directory -Force ` + -Path 'build\windows\test-results' | Out-Null + $logPath = 'build\windows\test-results\package-store.log' + try { + $packageOutput = @(& "$PSScriptRoot/package_store.ps1" ` + -ConfigPath $ConfigPath -Ci:$Ci *>&1 | + Tee-Object -FilePath $logPath) + } catch { + ($_ | Out-String).TrimEnd() | Add-Content -LiteralPath $logPath + throw + } + $metadata = @($packageOutput | Where-Object { + $_ -is [pscustomobject] -and + $_.PSObject.Properties.Name -contains 'Package' -and + $_.PSObject.Properties.Name -contains 'SHA256' + } | Select-Object -Last 1) + if ($metadata.Count -ne 1) { + throw 'Windows package generation did not return package metadata.' + } + $metadata = $metadata[0] + $metadata | Format-List | Out-Host + $metadata +} + +switch ($Stage) { + 'PesterTests' { Invoke-BusyMaxPesterTests } + 'SourceGeneration' { Invoke-BusyMaxSourceGeneration } + 'StaticAnalysis' { Invoke-BusyMaxStaticAnalysis } + 'FlutterTests' { Invoke-BusyMaxFlutterTests } + 'WindowsCompile' { Invoke-BusyMaxWindowsCompile } + 'NativeTests' { Invoke-BusyMaxNativeTests } + 'Package' { Invoke-BusyMaxPackage } + 'All' { + Invoke-BusyMaxPesterTests + Invoke-BusyMaxSourceGeneration + Invoke-BusyMaxStaticAnalysis + Invoke-BusyMaxFlutterTests + Invoke-BusyMaxWindowsCompile + Invoke-BusyMaxNativeTests + Invoke-BusyMaxPackage + } +} diff --git a/tool/windows/check_prerequisites.ps1 b/tool/windows/check_prerequisites.ps1 new file mode 100644 index 00000000..0fd008c6 --- /dev/null +++ b/tool/windows/check_prerequisites.ps1 @@ -0,0 +1,78 @@ +param( + [switch]$RequireWindows11, + [switch]$RequireWack +) + +. "$PSScriptRoot/common.ps1" + +if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitOperatingSystem) { + throw 'BusyMax Windows builds require 64-bit Windows 11.' +} +$operatingSystem = Get-CimInstance -ClassName Win32_OperatingSystem +if ($null -eq $operatingSystem) { + throw 'Win32_OperatingSystem could not be queried for Windows validation.' +} +try { + $hostBuild = [int]$operatingSystem.BuildNumber + $hostProductType = [uint32]$operatingSystem.ProductType +} catch { + throw "Win32_OperatingSystem returned invalid validation data: $($_.Exception.Message)" +} +$windows11ValidationHost = Test-BusyMaxWindows11ValidationHost ` + -Build $hostBuild -ProductType $hostProductType +if ($RequireWindows11) { + Assert-BusyMaxWindows11ValidationHost -Build $hostBuild ` + -ProductType $hostProductType +} +$flutterVersion = (& flutter --version --machine | ConvertFrom-Json).frameworkVersion +if ($flutterVersion -ne '3.44.4') { + throw "BusyMax requires Flutter 3.44.4; found $flutterVersion." +} +$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' +if (-not (Test-Path -LiteralPath $vswhere)) { + throw 'Visual Studio 2022 with Desktop development with C++ is required.' +} +$visualStudio = & $vswhere -latest -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property installationPath +if ([string]::IsNullOrWhiteSpace($visualStudio)) { + throw 'Visual Studio C++ x64 desktop tools were not found.' +} +$visualStudioVersion = & $vswhere -latest -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property installationVersion +$visualStudioDisplayName = & $vswhere -latest -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property displayName +$kitsBin = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' +$sdk = Get-BusyMaxWindowsSdkDirectory -KitsBin $kitsBin +if ($null -eq $sdk) { throw 'Windows SDK 10.0.26100.0 or newer is required.' } +foreach ($tool in @('makeappx.exe', 'signtool.exe')) { + if (-not (Test-Path -LiteralPath (Join-Path $sdk.FullName "x64\$tool"))) { + throw "$tool was not found in Windows SDK $($sdk.Name)." + } +} +if ($RequireWack) { + $appCert = Join-Path ${env:ProgramFiles(x86)} ` + 'Windows Kits\10\App Certification Kit\appcert.exe' + if (-not (Test-Path -LiteralPath $appCert)) { + throw 'The Windows App Certification Kit is required.' + } +} +$windowsVersion = Get-ItemProperty -LiteralPath ` + 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' +[pscustomobject]@{ + Flutter = $flutterVersion + VisualStudio = $visualStudio + VisualStudioDisplayName = $visualStudioDisplayName + VisualStudioVersion = $visualStudioVersion + WindowsSdk = $sdk.Name + WindowsSdkBin = Join-Path $sdk.FullName 'x64' + HostProductName = [string]$windowsVersion.ProductName + HostEditionId = [string]$windowsVersion.EditionID + HostDisplayVersion = [string]$windowsVersion.DisplayVersion + HostVersion = [Environment]::OSVersion.Version.ToString() + HostBuild = $hostBuild + HostProductType = $hostProductType + Windows11ValidationHost = $windows11ValidationHost +} diff --git a/tool/windows/common.ps1 b/tool/windows/common.ps1 new file mode 100644 index 00000000..6d78a355 --- /dev/null +++ b/tool/windows/common.ps1 @@ -0,0 +1,431 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-BusyMaxStoreConfig { + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Windows Store configuration does not exist: $Path" + } + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Get-BusyMaxWindowsSdkDirectory { + param( + [Parameter(Mandatory)][string]$KitsBin, + [version]$MinimumVersion = [version]'10.0.26100.0' + ) + return Get-ChildItem -LiteralPath $KitsBin -Directory | + Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } | + Where-Object { [version]$_.Name -ge $MinimumVersion } | + Sort-Object { [version]$_.Name } -Descending | + Select-Object -First 1 +} + +function Test-BusyMaxWindows11ValidationHost { + param( + [Parameter(Mandatory)][int]$Build, + [Parameter(Mandatory)][uint32]$ProductType + ) + return $ProductType -eq 1 -and $Build -ge 26100 +} + +function Assert-BusyMaxWindows11ValidationHost { + param( + [Parameter(Mandatory)][int]$Build, + [Parameter(Mandatory)][uint32]$ProductType + ) + if ($ProductType -ne 1) { + throw "Installed-package and WACK validation require a Windows 11 workstation (Win32_OperatingSystem.ProductType 1); found ProductType $ProductType." + } + if ($Build -lt 26100) { + throw "Installed-package and WACK validation require Windows 11 24H2/build 26100 or newer; found build $Build." + } +} + +function Get-BusyMaxPackageFiles { + param([Parameter(Mandatory)][string]$PackageRoot) + if (-not (Test-Path -LiteralPath $PackageRoot -PathType Container)) { + throw "Package root does not exist: $PackageRoot" + } + return @(Get-ChildItem -LiteralPath $PackageRoot -Recurse -File -Force) +} + +function Get-BusyMaxPackageRelativePath { + param( + [Parameter(Mandatory)][string]$PackageRoot, + [Parameter(Mandatory)][string]$Path + ) + return [IO.Path]::GetRelativePath($PackageRoot, $Path).Replace('\', '/') +} + +function Test-BusyMaxProhibitedPackageFile { + param( + [Parameter(Mandatory)][string]$PackageRoot, + [Parameter(Mandatory)][IO.FileInfo]$File + ) + $relative = Get-BusyMaxPackageRelativePath -PackageRoot $PackageRoot ` + -Path $File.FullName + return $File.Extension -in @( + '.pfx', '.cer', '.log', '.db', '.sqlite', '.dart', '.cc', '.h', '.pdb', + '.cpp', '.c', '.hpp', '.cmake', '.ps1', '.jsonl', '.yaml', '.yml', + '.md', '.p12', '.pem', '.key', '.crt', '.p7x', '.snap', '.AppImage' + ) -or + ($File.Extension -eq '.so' -and $relative -cne 'data/app.so') -or + $File.Name -match '(?i)(^|[._-])(test|fake|credential|secret|token|development)([._-]|$)|flutter_logo' -or + $relative -match '(?i)(^|/)(linux|snap|snapcraft)(/|$)' +} + +function Assert-BusyMaxPackageHasNoProhibitedFiles { + param([Parameter(Mandatory)][string]$PackageRoot) + $prohibited = @(Get-BusyMaxPackageFiles -PackageRoot $PackageRoot | + Where-Object { + Test-BusyMaxProhibitedPackageFile -PackageRoot $PackageRoot -File $_ + }) + if ($prohibited.Count -gt 0) { + throw "Prohibited package content: $($prohibited.FullName -join ', ')" + } +} + +function Assert-BusyMaxFinalMsixMetadata { + param([Parameter(Mandatory)][string]$PackageRoot) + foreach ($metadataFile in @('AppxBlockMap.xml', '[Content_Types].xml')) { + if (-not (Test-Path -LiteralPath (Join-Path $PackageRoot $metadataFile) ` + -PathType Leaf)) { + throw "Final MSIX metadata is missing: $metadataFile" + } + } +} + +function Expand-BusyMaxMsixMetadata { + param( + [Parameter(Mandatory)][string]$PackagePath, + [Parameter(Mandatory)][string]$PackageRoot + ) + if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + throw "MSIX package does not exist: $PackagePath" + } + if (-not (Test-Path -LiteralPath $PackageRoot -PathType Container)) { + throw "Unpacked MSIX directory does not exist: $PackageRoot" + } + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead( + (Resolve-Path -LiteralPath $PackagePath).Path) + try { + foreach ($metadataFile in @('AppxBlockMap.xml', '[Content_Types].xml')) { + $entries = @($archive.Entries | Where-Object { + $_.FullName -ceq $metadataFile + }) + if ($entries.Count -ne 1) { + throw "Exact MSIX archive must contain one $metadataFile entry; found $($entries.Count)." + } + $destination = Join-Path $PackageRoot $metadataFile + $inputStream = $entries[0].Open() + try { + $outputStream = [IO.File]::Open( + $destination, [IO.FileMode]::Create, [IO.FileAccess]::Write, + [IO.FileShare]::None) + try { + $inputStream.CopyTo($outputStream) + } finally { + $outputStream.Dispose() + } + } finally { + $inputStream.Dispose() + } + } + } finally { + $archive.Dispose() + } +} + +function Get-BusyMaxPackageInventory { + param([Parameter(Mandatory)][string]$PackageRoot) + return @(Get-BusyMaxPackageFiles -PackageRoot $PackageRoot | + Sort-Object FullName | + ForEach-Object { + [pscustomobject]@{ + Path = Get-BusyMaxPackageRelativePath -PackageRoot $PackageRoot ` + -Path $_.FullName + Length = $_.Length + SHA256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + } + }) +} + +function Test-BusyMaxPlaceholder { + param([AllowNull()][string]$Value) + return [string]::IsNullOrWhiteSpace($Value) -or + $Value -match '(?i)(replace_with|placeholder|your[-_]|example\.com|todo)' +} + +function Assert-BusyMaxMsixVersion { + param( + [Parameter(Mandatory)][string]$Version, + [string]$PreviousVersion = '' + ) + if ($Version -notmatch '^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$') { + throw 'BUSYMAX_MSIX_VERSION must have exactly four numeric components.' + } + $parts = @($Matches[1], $Matches[2], $Matches[3], $Matches[4]) | + ForEach-Object { [uint64]$_ } + if ($parts[0] -lt 1) { throw 'The first MSIX version component must be at least 1.' } + if ($parts[3] -ne 0) { throw 'The fourth MSIX version component is reserved and must be 0.' } + if ($parts | Where-Object { $_ -gt 65535 }) { + throw 'Each MSIX version component must be no greater than 65535.' + } + if (-not [string]::IsNullOrWhiteSpace($PreviousVersion)) { + Assert-BusyMaxMsixVersion -Version $PreviousVersion + if ([version]$Version -le [version]$PreviousVersion) { + throw "MSIX version $Version must be greater than $PreviousVersion." + } + } +} + +function Assert-BusyMaxHttpsUrl { + param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Value) + $uri = $null + if (-not [uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$uri) -or + $uri.Scheme -ne 'https' -or [string]::IsNullOrWhiteSpace($uri.Host)) { + throw "$Name must be a valid HTTPS URL." + } +} + +function Assert-BusyMaxCertificatePublisher { + param( + [Parameter(Mandatory)][string]$CertificateSubject, + [Parameter(Mandatory)][string]$ManifestPublisher + ) + if ($CertificateSubject -cne $ManifestPublisher) { + throw "Certificate subject '$CertificateSubject' does not exactly match AppxManifest Publisher '$ManifestPublisher' in LocalTestSigning mode." + } +} + +function Assert-BusyMaxStoreConfig { + param( + [Parameter(Mandatory)]$Config, + [Parameter(Mandatory)] + [ValidateSet('ProductionStore', 'CiNonProduction', 'LocalTestSigning')] + [string]$Mode + ) + foreach ($field in @( + 'identityName', 'publisher', 'publisherDisplayName', 'msixVersion', + 'privacyPolicyUrl', 'supportUrl', 'googleOAuthClientId', + 'microsoftOAuthClientId', 'microsoftOAuthAuthorityTenant', + 'homepageUrl')) { + if (Test-BusyMaxPlaceholder -Value ([string]$Config.$field)) { + throw "Store configuration field '$field' is missing or still a placeholder in $Mode mode." + } + } + switch ($Mode) { + 'ProductionStore' { + if ($Config.production -ne $true) { + throw "Field 'production' must be true in ProductionStore mode." + } + if ([string]$Config.identityName -ceq 'BusyStack.BusyMax.CI' -or + [string]$Config.publisher -ceq 'CN=BusyMax CI Package') { + throw "Field 'identityName' and 'publisher' must not use the committed CI identity in ProductionStore mode." + } + } + 'LocalTestSigning' { + if ($Config.production -ne $true) { + throw "Field 'production' must be true in LocalTestSigning mode because the test certificate must match the owner's rendered Store identity." + } + if ([string]$Config.identityName -ceq 'BusyStack.BusyMax.CI' -or + [string]$Config.publisher -ceq 'CN=BusyMax CI Package') { + throw "Field 'identityName' and 'publisher' must use the owner's Store identity, not the committed CI identity, in LocalTestSigning mode." + } + } + 'CiNonProduction' { + if ($Config.production -ne $false) { + throw "Field 'production' must be false in CiNonProduction mode. Production configurations cannot be used by CI." + } + $expected = [ordered]@{ + identityName = 'BusyStack.BusyMax.CI' + publisher = 'CN=BusyMax CI Package' + publisherDisplayName = 'BusyMax CI' + msixVersion = '1.0.0.0' + privacyPolicyUrl = 'https://busystack.org/privacy' + supportUrl = 'https://busystack.org/support' + homepageUrl = 'https://busystack.org' + googleOAuthClientId = 'busymax-ci-google-client-id' + microsoftOAuthClientId = 'busymax-ci-microsoft-client-id' + microsoftOAuthAuthorityTenant = 'common' + } + foreach ($entry in $expected.GetEnumerator()) { + $property = $Config.PSObject.Properties[$entry.Key] + if ($null -eq $property -or + [string]$property.Value -cne [string]$entry.Value) { + throw "Field '$($entry.Key)' must equal the committed nonproduction test value in CiNonProduction mode." + } + } + } + } + if ($Config.fakeData -eq $true) { + throw "Field 'fakeData' must be false in $Mode mode." + } + if ($Config.developmentBackend -eq $true) { + throw "Field 'developmentBackend' must be false in $Mode mode." + } + if ($Config.identityName -notmatch '^[A-Za-z0-9.-]{3,50}$') { + throw "Field 'identityName' must be a valid Partner Center package identity name in $Mode mode." + } + if ($Config.publisher -notmatch '^CN=.+') { + throw "Field 'publisher' must be the exact Partner Center Publisher CN in $Mode mode." + } + if (-not [string]::IsNullOrWhiteSpace($Config.googleOAuthClientSecret) -and + (Test-BusyMaxPlaceholder -Value ([string]$Config.googleOAuthClientSecret))) { + throw "Field 'googleOAuthClientSecret' is still a placeholder in $Mode mode." + } + try { + Assert-BusyMaxMsixVersion -Version ([string]$Config.msixVersion) ` + -PreviousVersion ([string]$Config.previousMsixVersion) + } catch { + throw "Field 'msixVersion' is invalid in $Mode mode: $($_.Exception.Message)" + } + foreach ($urlField in @('privacyPolicyUrl', 'supportUrl', 'homepageUrl')) { + try { + Assert-BusyMaxHttpsUrl -Name $urlField -Value $Config.$urlField + } catch { + throw "Field '$urlField' is invalid in $Mode mode: $($_.Exception.Message)" + } + } +} + +function Get-BusyMaxPackageFamilyName { + param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Publisher) + if (-not ('BusyMax.PackageIdentity' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +namespace BusyMax { + public static class PackageIdentity { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct PACKAGE_ID { + public uint reserved; + public uint processorArchitecture; + public ulong version; + [MarshalAs(UnmanagedType.LPWStr)] public string name; + [MarshalAs(UnmanagedType.LPWStr)] public string publisher; + [MarshalAs(UnmanagedType.LPWStr)] public string resourceId; + [MarshalAs(UnmanagedType.LPWStr)] public string publisherId; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int PackageFamilyNameFromId( + ref PACKAGE_ID packageId, ref uint packageFamilyNameLength, + StringBuilder packageFamilyName); + public static string FamilyName(string name, string publisher) { + var id = new PACKAGE_ID { name = name, publisher = publisher }; + // PACKAGE_FAMILY_NAME_MAX_LENGTH is 64 characters, excluding the + // terminating null. A fixed buffer avoids a nullable output pointer in + // managed interop and follows Microsoft's documented C# example. + uint length = 65; + var value = new StringBuilder((int)length); + int result = PackageFamilyNameFromId(ref id, ref length, value); + if (result != 0) throw new Win32Exception(result, + "PackageFamilyNameFromId failed"); + var familyName = value.ToString(); + if (String.IsNullOrWhiteSpace(familyName)) throw new InvalidOperationException( + "PackageFamilyNameFromId returned an empty package family name"); + return familyName; + } + } +} +'@ + } + return [BusyMax.PackageIdentity]::FamilyName($Name, $Publisher) +} + +function Get-BusyMaxResourceLanguages { + $locales = Get-ChildItem -LiteralPath 'lib/l10n' -Filter 'app_*.arb' | + ForEach-Object { + $json = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + $translationKeys = @($json.PSObject.Properties.Name | + Where-Object { -not $_.StartsWith('@') }) + # Flutter requires an empty generic `pt` catalog to generate pt-PT, but + # BusyMax does not expose generic Portuguese as a supported locale. + if ($translationKeys.Count -gt 0) { + $locale = ([string]$json.'@@locale').Replace('_', '-') + if ($locale -notmatch '^[A-Za-z]{2,3}(?:-[A-Za-z]{4})?(?:-[A-Za-z]{2}|-[0-9]{3})?$') { + throw "ARB locale is not a valid BCP-47 language tag: $locale" + } + try { + [Globalization.CultureInfo]::GetCultureInfo($locale) | Out-Null + } catch { + throw "ARB locale is not recognized as a BCP-47 language tag: $locale" + } + $locale + } + } | Sort-Object -Unique + if (-not $locales) { throw 'No BusyMax ARB locales were found.' } + return @($locales) +} + +function Get-BusyMaxResourceXml { + return (Get-BusyMaxResourceLanguages | ForEach-Object { + " " + }) -join "`r`n" +} + +function Get-BusyMaxVCRuntimeDirectory { + param([Parameter(Mandatory)][string]$RedistRoot) + $rankedDirectories = @(Get-ChildItem -LiteralPath $RedistRoot -Directory | + ForEach-Object { + $directory = $_ + try { + [pscustomobject]@{ + Directory = $directory + IsNumericVersion = 1 + Version = [version]$directory.Name + } + } catch { + # Recent Visual Studio layouts can include aliases such as `v145` + # beside numeric redistributable versions. Keep them as a fallback. + [pscustomobject]@{ + Directory = $directory + IsNumericVersion = 0 + Version = [version]'0.0' + } + } + } | Sort-Object -Property ` + @{ Expression = 'IsNumericVersion'; Descending = $true }, ` + @{ Expression = 'Version'; Descending = $true }, ` + @{ Expression = { $_.Directory.Name }; Descending = $true }) + foreach ($entry in $rankedDirectories) { + $candidate = Get-ChildItem -LiteralPath ` + (Join-Path $entry.Directory.FullName 'x64') -Directory ` + -Filter 'Microsoft.VC*.CRT' -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | Select-Object -First 1 + if ($null -ne $candidate) { return $candidate } + } + return $null +} + +function Copy-BusyMaxVCRuntime { + param( + [Parameter(Mandatory)][string]$VisualStudioPath, + [Parameter(Mandatory)][string]$Destination + ) + $redistRoot = Join-Path $VisualStudioPath 'VC\Redist\MSVC' + $crt = Get-BusyMaxVCRuntimeDirectory -RedistRoot $redistRoot + if ($null -eq $crt) { + throw 'The Visual Studio x64 VC runtime redistributable was not found.' + } + $runtimeNames = @( + 'concrt140.dll', 'msvcp140.dll', 'msvcp140_1.dll', 'msvcp140_2.dll', + 'msvcp140_atomic_wait.dll', 'msvcp140_codecvt_ids.dll', 'vccorlib140.dll', + 'vcruntime140.dll', 'vcruntime140_1.dll', 'vcruntime140_threads.dll' + ) + foreach ($name in $runtimeNames) { + $source = Join-Path $crt.FullName $name + if (Test-Path -LiteralPath $source) { + Copy-Item -LiteralPath $source -Destination $Destination + } + } + foreach ($required in @('msvcp140.dll', 'vcruntime140.dll', 'vcruntime140_1.dll')) { + if (-not (Test-Path -LiteralPath (Join-Path $Destination $required))) { + throw "Required Visual C++ runtime file was not staged: $required" + } + } +} diff --git a/tool/windows/create_test_certificate.ps1 b/tool/windows/create_test_certificate.ps1 new file mode 100644 index 00000000..2af34fee --- /dev/null +++ b/tool/windows/create_test_certificate.ps1 @@ -0,0 +1,34 @@ +param( + [Parameter(Mandatory)][string]$ConfigPath, + [string]$OutputDirectory = 'build\windows\test-signing' +) + +. "$PSScriptRoot/common.ps1" +$config = Get-BusyMaxStoreConfig -Path $ConfigPath +Assert-BusyMaxStoreConfig -Config $config -Mode LocalTestSigning +New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null +$certificate = New-SelfSignedCertificate ` + -Type Custom ` + -Subject $config.publisher ` + -KeyUsage DigitalSignature ` + -FriendlyName 'BusyMax local MSIX test certificate' ` + -CertStoreLocation 'Cert:\CurrentUser\My' ` + -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3') +try { + Assert-BusyMaxCertificatePublisher ` + -CertificateSubject ([string]$certificate.Subject) ` + -ManifestPublisher ([string]$config.publisher) +} catch { + Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($certificate.Thumbprint)" -Force + throw +} +$password = Read-Host 'Choose a local PFX password' -AsSecureString +$pfx = Join-Path $OutputDirectory 'busymax-test-only.pfx' +$cer = Join-Path $OutputDirectory 'busymax-test-only.cer' +Export-PfxCertificate -Cert $certificate -FilePath $pfx -Password $password | Out-Null +Export-Certificate -Cert $certificate -FilePath $cer | Out-Null +Write-Host "Created local-only files under $OutputDirectory. Never commit them." +Write-Host "Thumbprint: $($certificate.Thumbprint)" +Write-Host "Certificate cleanup: Remove-Item -LiteralPath 'Cert:\CurrentUser\My\$($certificate.Thumbprint)'" +Write-Host "File cleanup: Remove-Item -LiteralPath '$OutputDirectory' -Recurse" +[pscustomobject]@{ Pfx = $pfx; Cer = $cer; Thumbprint = $certificate.Thumbprint } diff --git a/tool/windows/install_test_package.ps1 b/tool/windows/install_test_package.ps1 new file mode 100644 index 00000000..6e159efe --- /dev/null +++ b/tool/windows/install_test_package.ps1 @@ -0,0 +1,54 @@ +param( + [Parameter(Mandatory)][string]$ConfigPath, + [Parameter(Mandatory)][string]$PackagePath, + [Parameter(Mandatory)][string]$PfxPath +) + +. "$PSScriptRoot/common.ps1" +$prerequisites = & "$PSScriptRoot/check_prerequisites.ps1" -RequireWindows11 +$config = Get-BusyMaxStoreConfig -Path $ConfigPath +Assert-BusyMaxStoreConfig -Config $config -Mode LocalTestSigning +$password = Read-Host 'Local PFX password' -AsSecureString +$certificate = Import-PfxCertificate -FilePath $PfxPath ` + -CertStoreLocation 'Cert:\CurrentUser\My' -Password $password +$certificateSubject = [string]$certificate.Subject +Assert-BusyMaxCertificatePublisher -CertificateSubject $certificateSubject ` + -ManifestPublisher ([string]$config.publisher) +# The exact package manifest is checked below after a clean unpack. +$inspection = Join-Path ([IO.Path]::GetTempPath()) ` + "busymax-install-inspection-$([guid]::NewGuid().ToString('N'))" +& (Join-Path $prerequisites.WindowsSdkBin 'makeappx.exe') unpack /p $PackagePath /d $inspection /o +if ($LASTEXITCODE -ne 0) { + if (Test-Path -LiteralPath $inspection) { + Remove-Item -LiteralPath $inspection -Recurse -Force + } + throw 'Could not unpack the MSIX before local test signing.' +} +try { + [xml]$manifest = Get-Content ` + -LiteralPath (Join-Path $inspection 'AppxManifest.xml') -Raw +} finally { + Remove-Item -LiteralPath $inspection -Recurse -Force +} +$manifestPublisher = [string]$manifest.Package.Identity.Publisher +Assert-BusyMaxCertificatePublisher -CertificateSubject $certificateSubject ` + -ManifestPublisher $manifestPublisher +if ([string]$manifest.Package.Identity.Name -cne [string]$config.identityName) { + throw "Packed manifest identity '$($manifest.Package.Identity.Name)' does not match configuration field 'identityName' in LocalTestSigning mode." +} +if ([string]$manifest.Package.Identity.Version -cne [string]$config.msixVersion) { + throw "Packed manifest version '$($manifest.Package.Identity.Version)' does not match configuration field 'msixVersion' in LocalTestSigning mode." +} +$testPackage = Join-Path (Split-Path -Parent $PackagePath) 'BusyMax-test-signed.msix' +Copy-Item -LiteralPath $PackagePath -Destination $testPackage -Force +$signTool = Join-Path $prerequisites.WindowsSdkBin 'signtool.exe' +& $signTool sign /fd SHA256 /sha1 $certificate.Thumbprint /s My $testPackage +if ($LASTEXITCODE -ne 0) { throw 'Local test signing failed.' } +$trustedCertificate = Import-Certificate ` + -FilePath ([IO.Path]::ChangeExtension($PfxPath, '.cer')) ` + -CertStoreLocation 'Cert:\CurrentUser\TrustedPeople' +Add-AppxPackage -Path $testPackage +Write-Host 'Installed local test package. It is not a Store artifact.' +Write-Host "Cleanup: Get-AppxPackage -Name '$($config.identityName)' | Remove-AppxPackage" +Write-Host "Certificate cleanup (My): Remove-Item -LiteralPath 'Cert:\CurrentUser\My\$($certificate.Thumbprint)'" +Write-Host "Certificate cleanup (TrustedPeople): Remove-Item -LiteralPath 'Cert:\CurrentUser\TrustedPeople\$($trustedCertificate.Thumbprint)'" diff --git a/tool/windows/package_store.ps1 b/tool/windows/package_store.ps1 new file mode 100644 index 00000000..d2ea852d --- /dev/null +++ b/tool/windows/package_store.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory)][string]$ConfigPath, + [string]$ReleasePath = 'build\windows\x64\runner\Release', + [string]$OutputDirectory = 'build\windows\store', + [switch]$Ci +) + +. "$PSScriptRoot/common.ps1" +$prerequisites = & "$PSScriptRoot/check_prerequisites.ps1" +$config = Get-BusyMaxStoreConfig -Path $ConfigPath +$mode = if ($Ci) { 'CiNonProduction' } else { 'ProductionStore' } +Assert-BusyMaxStoreConfig -Config $config -Mode $mode +if (-not (Test-Path -LiteralPath "$ReleasePath/busymax.exe")) { + throw "Windows release output was not found: $ReleasePath" +} +$workspace = [IO.Path]::GetFullPath((Get-Location).Path) +$allowedOutputRoot = [IO.Path]::GetFullPath( + (Join-Path $workspace 'build\windows')) +$resolvedOutput = [IO.Path]::GetFullPath( + (Join-Path $workspace $OutputDirectory)) +$allowedPrefix = $allowedOutputRoot.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar +if (-not $resolvedOutput.StartsWith( + $allowedPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Windows package output must stay below build\windows.' +} +New-Item -ItemType Directory -Force -Path $resolvedOutput | Out-Null +$ownerMarker = Join-Path $resolvedOutput '.busymax-store-output' +$staging = Join-Path $OutputDirectory 'staging' +$unpacked = Join-Path $OutputDirectory 'unpacked-final-msix' +$package = Join-Path $OutputDirectory "BusyMax-$($config.msixVersion)-x64.msix" +foreach ($managedPath in @($staging, $unpacked, $package)) { + if ((Test-Path -LiteralPath $managedPath) -and + -not (Test-Path -LiteralPath $ownerMarker -PathType Leaf)) { + throw "Refusing to replace unowned Windows package output: $managedPath" + } +} +if (Test-Path -LiteralPath $staging) { + Remove-Item -LiteralPath $staging -Recurse -Force +} +Set-Content -LiteralPath $ownerMarker -Value 'busymax-windows-store-output-v1' ` + -Encoding ascii +New-Item -ItemType Directory -Force -Path $staging | Out-Null +Copy-Item -LiteralPath "$ReleasePath/busymax.exe" -Destination $staging +$runtimeFiles = @( + 'flutter_windows.dll', 'sqlite3.dll', + 'connectivity_plus_plugin.dll', 'file_selector_windows_plugin.dll', + 'flutter_local_notifications_windows.dll', + 'flutter_secure_storage_windows_plugin.dll', + 'flutter_timezone_plugin.dll', 'system_theme_plugin.dll', + 'tray_manager_plugin.dll', 'url_launcher_windows_plugin.dll' +) +foreach ($runtimeFile in $runtimeFiles) { + $source = Join-Path $ReleasePath $runtimeFile + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw "Required Windows runtime file was not built: $runtimeFile" + } + Copy-Item -LiteralPath $source -Destination $staging +} +Get-ChildItem -LiteralPath $ReleasePath -File -Filter '*.dat' | + Copy-Item -Destination $staging +Copy-BusyMaxVCRuntime -VisualStudioPath $prerequisites.VisualStudio ` + -Destination $staging +Copy-Item -LiteralPath "$ReleasePath/data" -Destination $staging -Recurse +$assets = Join-Path $staging 'Assets' +New-Item -ItemType Directory -Force -Path $assets | Out-Null +$logicalAssets = [ordered]@{ + 'StoreLogo.png' = 'StoreLogo.png' + 'Square44x44Logo.png' = 'Square44x44Logo.png' + 'Square150x150Logo.png' = 'Square150x150Logo.png' + 'FileAssociationLogo.png' = 'FileAssociationLogo.png' +} +foreach ($asset in $logicalAssets.GetEnumerator()) { + $source = Join-Path 'windows\runner\resources\msix' $asset.Value + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw "Required unqualified MSIX visual asset is missing: $source" + } + Copy-Item -LiteralPath $source -Destination (Join-Path $assets $asset.Key) +} +& "$PSScriptRoot/render_manifest.ps1" -ConfigPath $ConfigPath ` + -OutputPath (Join-Path $staging 'AppxManifest.xml') ` + -MaximumTestedVersion $prerequisites.WindowsSdk -Ci:$Ci +$stagingInventoryPath = Join-Path $OutputDirectory 'staging-inventory.json' +$stagingInventory = @(& "$PSScriptRoot/validate_package_contents.ps1" ` + -PackageRoot $staging -InventoryOutputPath $stagingInventoryPath ` + -ValidationMode Staging) +if (Test-Path -LiteralPath $package) { Remove-Item -LiteralPath $package -Force } +$makeAppx = Join-Path $prerequisites.WindowsSdkBin 'makeappx.exe' +& $makeAppx pack /v /d $staging /p $package /o +if ($LASTEXITCODE -ne 0) { + throw 'MakeAppx schema validation or packing failed.' +} +if (Test-Path -LiteralPath $unpacked) { + Remove-Item -LiteralPath $unpacked -Recurse -Force +} +& $makeAppx unpack /p $package /d $unpacked /o +if ($LASTEXITCODE -ne 0) { throw 'MakeAppx could not unpack the final MSIX.' } +# MakeAppx validates and unpacks package payloads but omits the OPC container +# member [Content_Types].xml. Restore both generated metadata members directly +# from the exact MSIX so validation and inventory cover the complete archive. +Expand-BusyMaxMsixMetadata -PackagePath $package -PackageRoot $unpacked +$finalInventoryPath = Join-Path $OutputDirectory 'final-msix-inventory.json' +$finalInventory = @(& "$PSScriptRoot/validate_package_contents.ps1" ` + -PackageRoot $unpacked -InventoryOutputPath $finalInventoryPath ` + -ValidationMode FinalMsix) +$packageMetadataFiles = @('AppxBlockMap.xml', '[Content_Types].xml') +$finalInventoryObjects = @($finalInventory | Where-Object { + $_ -is [pscustomobject] +}) +foreach ($metadataFile in $packageMetadataFiles) { + if ($metadataFile -cnotin $finalInventoryObjects.Path) { + throw "Final MSIX metadata is absent from the exact-package inventory: $metadataFile" + } +} +$expectedInventory = @($stagingInventory | Where-Object { + $_ -is [pscustomobject] +}) +$actualInventory = @($finalInventoryObjects | Where-Object { + $_.Path -notin $packageMetadataFiles +}) +$expectedLines = @($expectedInventory | ForEach-Object { + "$($_.Path)|$($_.Length)|$($_.SHA256)" +}) +$actualLines = @($actualInventory | ForEach-Object { + "$($_.Path)|$($_.Length)|$($_.SHA256)" +}) +$difference = Compare-Object -ReferenceObject $expectedLines ` + -DifferenceObject $actualLines +if ($difference) { + throw "The exact packed MSIX inventory differs from staging: $($difference.InputObject -join ', ')" +} +$hash = Get-FileHash -LiteralPath $package -Algorithm SHA256 +$metadata = [pscustomobject]@{ + Package = (Resolve-Path $package).Path + Version = $config.msixVersion + Architecture = 'x64' + MinimumOS = '10.0.26100.0' + MaximumTestedOS = $prerequisites.WindowsSdk + SHA256 = $hash.Hash + Inventory = (Resolve-Path $finalInventoryPath).Path +} +$metadataPath = Join-Path $OutputDirectory 'package-metadata.json' +$metadata | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath $metadataPath ` + -Encoding utf8NoBOM +$metadata diff --git a/tool/windows/render_manifest.ps1 b/tool/windows/render_manifest.ps1 new file mode 100644 index 00000000..18e39258 --- /dev/null +++ b/tool/windows/render_manifest.ps1 @@ -0,0 +1,31 @@ +param( + [Parameter(Mandatory)][string]$ConfigPath, + [Parameter(Mandatory)][string]$OutputPath, + [Parameter(Mandatory)][string]$MaximumTestedVersion, + [switch]$Ci +) + +. "$PSScriptRoot/common.ps1" +$config = Get-BusyMaxStoreConfig -Path $ConfigPath +$mode = if ($Ci) { 'CiNonProduction' } else { 'ProductionStore' } +Assert-BusyMaxStoreConfig -Config $config -Mode $mode +if ([version]$MaximumTestedVersion -lt [version]'10.0.26100.0') { + throw 'MaximumTestedVersion must be the Windows 11 24H2 SDK or newer.' +} +$template = Get-Content -LiteralPath "$PSScriptRoot/AppxManifest.xml.template" -Raw +$rendered = $template.Replace( + '@@IDENTITY_NAME@@', [Security.SecurityElement]::Escape($config.identityName)) +$rendered = $rendered.Replace( + '@@PUBLISHER@@', [Security.SecurityElement]::Escape($config.publisher)) +$rendered = $rendered.Replace( + '@@PUBLISHER_DISPLAY_NAME@@', + [Security.SecurityElement]::Escape($config.publisherDisplayName)) +$rendered = $rendered.Replace('@@PACKAGE_VERSION@@', $config.msixVersion) +$rendered = $rendered.Replace( + '@@MAX_TESTED_VERSION@@', $MaximumTestedVersion) +$rendered = $rendered.Replace( + '@@RESOURCE_LANGUAGES@@', (Get-BusyMaxResourceXml)) +$directory = Split-Path -Parent $OutputPath +New-Item -ItemType Directory -Force -Path $directory | Out-Null +[IO.File]::WriteAllText($OutputPath, $rendered, [Text.UTF8Encoding]::new($false)) +& "$PSScriptRoot/validate_manifest.ps1" -ManifestPath $OutputPath diff --git a/tool/windows/run_native_tests.ps1 b/tool/windows/run_native_tests.ps1 new file mode 100644 index 00000000..0ffff5bd --- /dev/null +++ b/tool/windows/run_native_tests.ps1 @@ -0,0 +1,39 @@ +param( + [string]$BuildDirectory = 'build\windows\x64', + [string]$Configuration = 'Release' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not $IsWindows) { throw 'BusyMax native tests require Windows.' } +if (-not (Test-Path -LiteralPath (Join-Path $BuildDirectory 'CMakeCache.txt'))) { + throw "The configured Windows CMake build was not found: $BuildDirectory" +} +$resultDirectory = 'build\windows\test-results' +New-Item -ItemType Directory -Force -Path $resultDirectory | Out-Null +& cmake -S windows -B $BuildDirectory ` + -Dinclude_flutter_timezone_tests=ON ` + -Dinclude_tray_manager_tests=ON 2>&1 | Tee-Object ` + -FilePath (Join-Path $resultDirectory 'native-configure.log') +$configureExitCode = $LASTEXITCODE +if ($configureExitCode -ne 0) { + throw "BusyMax native test configuration failed with exit code $configureExitCode." +} +& cmake --build $BuildDirectory --config $Configuration ` + --target busymax_runner_native_test flutter_timezone_test tray_manager_test ` + 2>&1 | Tee-Object ` + -FilePath (Join-Path $resultDirectory 'native-build.log') +$buildExitCode = $LASTEXITCODE +if ($buildExitCode -ne 0) { + throw "BusyMax native test compilation failed with exit code $buildExitCode." +} +$junitPath = [IO.Path]::GetFullPath( + (Join-Path $resultDirectory 'native-tests.xml')) +& ctest --test-dir $BuildDirectory -C $Configuration --output-on-failure ` + --output-junit $junitPath 2>&1 | + Tee-Object -FilePath (Join-Path $resultDirectory 'native-tests.log') +$testExitCode = $LASTEXITCODE +if ($testExitCode -ne 0) { + throw "BusyMax native tests failed with exit code $testExitCode." +} diff --git a/tool/windows/run_wack.ps1 b/tool/windows/run_wack.ps1 new file mode 100644 index 00000000..dfd1d050 --- /dev/null +++ b/tool/windows/run_wack.ps1 @@ -0,0 +1,66 @@ +param( + [Parameter(Mandatory)][string]$PackagePath, + [string]$ReportPath = 'build\windows\store\wack-report.xml', + [string]$WarningDispositionPath = '', + [string]$SummaryPath = 'build\windows\store\wack-summary.json' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. "$PSScriptRoot/wack_report.ps1" + +if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + throw "WACK package does not exist: $PackagePath" +} +$packageHash = (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash + +& "$PSScriptRoot/check_prerequisites.ps1" ` + -RequireWindows11 -RequireWack | Out-Host +$appCert = Join-Path ${env:ProgramFiles(x86)} ` + 'Windows Kits\10\App Certification Kit\appcert.exe' +$directory = Split-Path -Parent $ReportPath +New-Item -ItemType Directory -Force -Path $directory | Out-Null +& $appCert reset +if ($LASTEXITCODE -ne 0) { throw 'Windows App Certification Kit reset failed.' } +& $appCert test -appxpackagepath $PackagePath -reportoutputpath $ReportPath +$appCertExitCode = $LASTEXITCODE +$analysis = Read-BusyMaxWackReport -ReportPath $ReportPath +$warnings = @($analysis.Warnings) +if ($warnings.Count -gt 0) { + Write-Host 'WACK warnings:' + $warnings | ForEach-Object { Write-Host " - $_" } +} +Assert-BusyMaxWackWarningDispositions -Warnings $warnings ` + -WarningDispositionPath $WarningDispositionPath + +$summary = [pscustomobject]@{ + Package = [IO.Path]::GetFullPath($PackagePath) + PackageSHA256 = $packageHash + Report = [IO.Path]::GetFullPath($ReportPath) + OverallResult = $analysis.OverallResult + AppCertExitCode = $appCertExitCode + ResultCount = $analysis.ResultCount + FailureCount = $analysis.FailureNames.Count + Warnings = $warnings + WarningDispositionPath = if ($warnings.Count -gt 0) { + [IO.Path]::GetFullPath($WarningDispositionPath) + } else { $null } +} +$summaryDirectory = Split-Path -Parent $SummaryPath +if (-not [string]::IsNullOrWhiteSpace($summaryDirectory)) { + New-Item -ItemType Directory -Force -Path $summaryDirectory | Out-Null +} +$summary | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $SummaryPath ` + -Encoding utf8NoBOM + +if ($analysis.FailureNames.Count -gt 0) { + throw "WACK reported failed tests: $($analysis.FailureNames -join ', ')" +} +if ($analysis.OverallResult -notin @('PASS', 'PASSED', 'PASS WITH WARNINGS', 'PASSED WITH WARNINGS')) { + throw "WACK overall result is not passing: $($analysis.OverallResult)" +} +if ($appCertExitCode -ne 0) { + throw "WACK process exited with code $appCertExitCode despite producing a report." +} +Write-Host "WACK report passed: $ReportPath" +$summary diff --git a/tool/windows/tests/manifest_contract.Tests.ps1 b/tool/windows/tests/manifest_contract.Tests.ps1 new file mode 100644 index 00000000..6cb00b4c --- /dev/null +++ b/tool/windows/tests/manifest_contract.Tests.ps1 @@ -0,0 +1,92 @@ +BeforeAll { + $script:tools = Split-Path -Parent $PSScriptRoot + . (Join-Path $script:tools 'common.ps1') + + function New-BusyMaxRenderedTestManifest { + $rendered = Get-Content ` + -LiteralPath (Join-Path $script:tools 'AppxManifest.xml.template') -Raw + $rendered = $rendered.Replace( + '@@IDENTITY_NAME@@', 'BusyStack.BusyMax.CI') + $rendered = $rendered.Replace( + '@@PUBLISHER@@', 'CN=BusyMax CI Package') + $rendered = $rendered.Replace( + '@@PUBLISHER_DISPLAY_NAME@@', 'BusyMax CI') + $rendered = $rendered.Replace('@@PACKAGE_VERSION@@', '1.0.0.0') + $rendered = $rendered.Replace( + '@@MAX_TESTED_VERSION@@', '10.0.26100.0') + return $rendered.Replace( + '@@RESOURCE_LANGUAGES@@', (Get-BusyMaxResourceXml)) + } +} + +Describe 'BusyMax rendered manifest contract' { + BeforeEach { + $manifestPath = Join-Path $TestDrive 'AppxManifest.xml' + } + + It 'accepts toast activation in the base desktop namespace' { + New-BusyMaxRenderedTestManifest | Set-Content ` + -LiteralPath $manifestPath -Encoding utf8NoBOM + { & (Join-Path $tools 'validate_manifest.ps1') ` + -ManifestPath $manifestPath } | Should -Not -Throw + } + + It 'rejects the desktop4 toast activation structure' { + $rendered = New-BusyMaxRenderedTestManifest + $desktopNamespace = + ' xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"' + $rendered = $rendered.Replace( + $desktopNamespace, + "$desktopNamespace`r`n xmlns:desktop4=`"http://schemas.microsoft.com/appx/manifest/desktop/windows10/4`"") + $rendered = $rendered.Replace( + 'IgnorableNamespaces="uap uap10 desktop com rescap"', + 'IgnorableNamespaces="uap uap10 desktop desktop4 com rescap"') + $rendered = $rendered.Replace( + '', + '') + $rendered = $rendered.Replace( + '(\s*)', + '$1') + $rendered | Set-Content -LiteralPath $manifestPath -Encoding utf8NoBOM + { & (Join-Path $tools 'validate_manifest.ps1') ` + -ManifestPath $manifestPath } | + Should -Throw -ExpectedMessage '*desktop namespace, not desktop4*' + } + + It 'rejects a COM class that differs from the toast activator CLSID' { + $rendered = (New-BusyMaxRenderedTestManifest).Replace( + '', + '') + $rendered | Set-Content -LiteralPath $manifestPath -Encoding utf8NoBOM + { & (Join-Path $tools 'validate_manifest.ps1') ` + -ManifestPath $manifestPath } | + Should -Throw -ExpectedMessage '*Toast COM server*' + } + + It 'references only concrete unqualified package logo paths' { + [xml]$manifest = New-BusyMaxRenderedTestManifest + $namespaces = [System.Xml.XmlNamespaceManager]::new($manifest.NameTable) + $namespaces.AddNamespace( + 'f', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10') + $namespaces.AddNamespace( + 'uap', 'http://schemas.microsoft.com/appx/manifest/uap/windows10') + $visualElements = $manifest.SelectSingleNode( + '//uap:VisualElements', $namespaces) + $references = @( + [string]$manifest.SelectSingleNode( + '/f:Package/f:Properties/f:Logo', $namespaces).InnerText + [string]$visualElements.Square44x44Logo + [string]$visualElements.Square150x150Logo + [string]$manifest.SelectSingleNode( + '//uap:FileTypeAssociation/uap:Logo', $namespaces).InnerText + ) + $references | Should -HaveCount 4 + foreach ($reference in $references) { + $reference | Should -Not -Match '\.(scale|targetsize)-' + } + } +} diff --git a/tool/windows/tests/package_contents_contract.Tests.ps1 b/tool/windows/tests/package_contents_contract.Tests.ps1 new file mode 100644 index 00000000..48475c44 --- /dev/null +++ b/tool/windows/tests/package_contents_contract.Tests.ps1 @@ -0,0 +1,85 @@ +BeforeAll { + $script:tools = Split-Path -Parent $PSScriptRoot + . (Join-Path $script:tools 'common.ps1') +} + +Describe 'BusyMax exact-package file inspection' { + BeforeEach { + $packageRoot = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $packageRoot | Out-Null + } + + It 'rejects a hidden prohibited package file' { + $hiddenSecret = New-Item -ItemType File ` + -Path (Join-Path $packageRoot '.hidden-secret.key') + if ($IsWindows) { + $hiddenSecret.Attributes = $hiddenSecret.Attributes -bor ` + [IO.FileAttributes]::Hidden + } + + { Assert-BusyMaxPackageHasNoProhibitedFiles -PackageRoot $packageRoot } | + Should -Throw -ExpectedMessage '*Prohibited package content*hidden-secret.key*' + } + + It 'requires and inventories hidden final-MSIX metadata' { + $metadataNames = @('AppxBlockMap.xml', '[Content_Types].xml') + foreach ($metadataName in $metadataNames) { + $metadataPath = Join-Path $packageRoot $metadataName + [IO.File]::WriteAllText($metadataPath, 'package metadata') + $metadata = Get-Item -LiteralPath $metadataPath + if ($IsWindows) { + $metadata.Attributes = $metadata.Attributes -bor ` + [IO.FileAttributes]::Hidden + } + } + + { Assert-BusyMaxFinalMsixMetadata -PackageRoot $packageRoot } | + Should -Not -Throw + $inventory = @(Get-BusyMaxPackageInventory -PackageRoot $packageRoot) + $inventory.Path | Should -Contain 'AppxBlockMap.xml' + $inventory.Path | Should -Contain '[Content_Types].xml' + } + + It 'rejects a final-MSIX inventory missing either metadata file' { + [IO.File]::WriteAllText( + (Join-Path $packageRoot 'AppxBlockMap.xml'), 'package metadata') + + { Assert-BusyMaxFinalMsixMetadata -PackageRoot $packageRoot } | + Should -Throw -ExpectedMessage '*Content_Types*.xml*' + } + + It 'extracts generated metadata from the exact MSIX container' { + $archiveSource = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $archiveSource | Out-Null + [IO.File]::WriteAllText( + (Join-Path $archiveSource 'AppxBlockMap.xml'), 'block map') + [IO.File]::WriteAllText( + (Join-Path $archiveSource '[Content_Types].xml'), 'content types') + [IO.File]::WriteAllText( + (Join-Path $archiveSource 'busymax.exe'), 'payload') + $packagePath = Join-Path $TestDrive ` + "$([guid]::NewGuid().ToString('N')).msix" + Add-Type -AssemblyName System.IO.Compression.FileSystem + [IO.Compression.ZipFile]::CreateFromDirectory($archiveSource, $packagePath) + + Expand-BusyMaxMsixMetadata -PackagePath $packagePath ` + -PackageRoot $packageRoot + + Get-Content -LiteralPath (Join-Path $packageRoot 'AppxBlockMap.xml') | + Should -Be 'block map' + Get-Content -LiteralPath (Join-Path $packageRoot '[Content_Types].xml') | + Should -Be 'content types' + } + + It 'wires distinct staging and final-MSIX validation modes' { + $validator = Get-Content ` + -LiteralPath (Join-Path $tools 'validate_package_contents.ps1') -Raw + $packager = Get-Content ` + -LiteralPath (Join-Path $tools 'package_store.ps1') -Raw + + $validator | Should -Match "ValidateSet\('Staging', 'FinalMsix'\)" + $packager | Should -Match '-ValidationMode Staging' + $packager | Should -Match '-ValidationMode FinalMsix' + $packager | Should -Match 'Expand-BusyMaxMsixMetadata' + } +} diff --git a/tool/windows/tests/prerequisite_contract.Tests.ps1 b/tool/windows/tests/prerequisite_contract.Tests.ps1 new file mode 100644 index 00000000..6d2d076b --- /dev/null +++ b/tool/windows/tests/prerequisite_contract.Tests.ps1 @@ -0,0 +1,83 @@ +BeforeAll { + . (Join-Path (Split-Path -Parent $PSScriptRoot) 'common.ps1') +} + +Describe 'BusyMax Windows SDK discovery' { + It 'ignores architecture directories and selects the newest supported SDK' { + $kitsBin = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $kitsBin | Out-Null + foreach ($directory in @( + 'arm', 'arm64', 'x64', 'x86', '10.0.22621.0', + '10.0.26100.0', '10.0.30000.0')) { + New-Item -ItemType Directory -Path (Join-Path $kitsBin $directory) | + Out-Null + } + + $sdk = Get-BusyMaxWindowsSdkDirectory -KitsBin $kitsBin + + $sdk.Name | Should -Be '10.0.30000.0' + } + + It 'returns no SDK when only architecture and unsupported folders exist' { + $kitsBin = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $kitsBin | Out-Null + foreach ($directory in @('arm64', 'x64', '10.0.22621.0', 'not-a-version')) { + New-Item -ItemType Directory -Path (Join-Path $kitsBin $directory) | + Out-Null + } + + $sdk = Get-BusyMaxWindowsSdkDirectory -KitsBin $kitsBin + + $sdk | Should -BeNullOrEmpty + } +} + +Describe 'BusyMax Windows 11 validation-host detection' { + It 'accepts Windows 11 24H2 workstation build 26100' { + Test-BusyMaxWindows11ValidationHost -Build 26100 -ProductType 1 | + Should -BeTrue + { Assert-BusyMaxWindows11ValidationHost -Build 26100 -ProductType 1 } | + Should -Not -Throw + } + + It 'rejects Windows Server build 26100' { + Test-BusyMaxWindows11ValidationHost -Build 26100 -ProductType 3 | + Should -BeFalse + { Assert-BusyMaxWindows11ValidationHost -Build 26100 -ProductType 3 } | + Should -Throw -ExpectedMessage '*ProductType 1*found ProductType 3*' + } + + It 'rejects a workstation older than Windows 11 24H2' { + Test-BusyMaxWindows11ValidationHost -Build 22631 -ProductType 1 | + Should -BeFalse + { Assert-BusyMaxWindows11ValidationHost -Build 22631 -ProductType 1 } | + Should -Throw -ExpectedMessage '*build 26100*found build 22631*' + } +} + +Describe 'BusyMax Visual C++ runtime discovery' { + It 'prefers the newest numeric runtime and tolerates toolset aliases' { + $visualStudio = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $redistRoot = Join-Path $visualStudio 'VC\Redist\MSVC' + $numericCrt = Join-Path $redistRoot ` + '14.50.35717\x64\Microsoft.VC145.CRT' + $aliasCrt = Join-Path $redistRoot 'v145\x64\Microsoft.VC145.CRT' + New-Item -ItemType Directory -Path $numericCrt -Force | Out-Null + New-Item -ItemType Directory -Path $aliasCrt -Force | Out-Null + foreach ($name in @( + 'msvcp140.dll', 'vcruntime140.dll', 'vcruntime140_1.dll')) { + Set-Content -LiteralPath (Join-Path $numericCrt $name) ` + -Value 'numeric' -Encoding ascii + Set-Content -LiteralPath (Join-Path $aliasCrt $name) ` + -Value 'alias' -Encoding ascii + } + $destination = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $destination | Out-Null + + Copy-BusyMaxVCRuntime -VisualStudioPath $visualStudio ` + -Destination $destination + + Get-Content -LiteralPath (Join-Path $destination 'msvcp140.dll') | + Should -Be 'numeric' + } +} diff --git a/tool/windows/tests/store_config.Tests.ps1 b/tool/windows/tests/store_config.Tests.ps1 new file mode 100644 index 00000000..84773214 --- /dev/null +++ b/tool/windows/tests/store_config.Tests.ps1 @@ -0,0 +1,114 @@ +BeforeAll { + . (Join-Path (Split-Path -Parent $PSScriptRoot) 'common.ps1') + + function New-ValidConfig { + param([bool]$Production = $true) + return [pscustomobject]@{ + production = $Production + identityName = 'BusyStack.BusyMax.Owner' + publisher = 'CN=BusyMax Owner' + publisherDisplayName = 'BusyMax Owner' + msixVersion = '1.2.3.0' + previousMsixVersion = '1.2.2.0' + privacyPolicyUrl = 'https://busystack.org/privacy' + supportUrl = 'https://busystack.org/support' + homepageUrl = 'https://busystack.org' + googleOAuthClientId = 'google-owner-client-id' + googleOAuthClientSecret = '' + microsoftOAuthClientId = 'microsoft-owner-client-id' + microsoftOAuthAuthorityTenant = 'common' + fakeData = $false + developmentBackend = $false + } + } + + function New-CiConfig { + $config = New-ValidConfig -Production $false + $config.identityName = 'BusyStack.BusyMax.CI' + $config.publisher = 'CN=BusyMax CI Package' + $config.publisherDisplayName = 'BusyMax CI' + $config.msixVersion = '1.0.0.0' + $config.previousMsixVersion = '' + $config.googleOAuthClientId = 'busymax-ci-google-client-id' + $config.microsoftOAuthClientId = 'busymax-ci-microsoft-client-id' + return $config + } +} + +Describe 'BusyMax Store configuration validation modes' { + It 'accepts a production Store configuration only in ProductionStore mode' { + $config = New-ValidConfig + { Assert-BusyMaxStoreConfig -Config $config -Mode ProductionStore } | + Should -Not -Throw + { Assert-BusyMaxStoreConfig -Config $config -Mode CiNonProduction } | + Should -Throw + } + + It 'accepts the exact committed CI identity only in CiNonProduction mode' { + $config = New-CiConfig + { Assert-BusyMaxStoreConfig -Config $config -Mode CiNonProduction } | + Should -Not -Throw + { Assert-BusyMaxStoreConfig -Config $config -Mode ProductionStore } | + Should -Throw + { Assert-BusyMaxStoreConfig -Config $config -Mode LocalTestSigning } | + Should -Throw + $config.identityName = 'BusyStack.BusyMax.NotTheCommittedCiIdentity' + { Assert-BusyMaxStoreConfig -Config $config -Mode CiNonProduction } | + Should -Throw + } + + It 'accepts owner production identity in LocalTestSigning mode' { + { Assert-BusyMaxStoreConfig -Config (New-ValidConfig) ` + -Mode LocalTestSigning } | Should -Not -Throw + } + + It 'rejects placeholders in every mode' { + $production = New-ValidConfig + $production.identityName = 'REPLACE_WITH_IDENTITY' + { Assert-BusyMaxStoreConfig -Config $production -Mode ProductionStore } | + Should -Throw + $local = New-ValidConfig + $local.identityName = 'REPLACE_WITH_IDENTITY' + { Assert-BusyMaxStoreConfig -Config $local -Mode LocalTestSigning } | + Should -Throw + $ci = New-CiConfig + $ci.supportUrl = 'https://example.com/support' + { Assert-BusyMaxStoreConfig -Config $ci -Mode CiNonProduction } | + Should -Throw + } + + It 'rejects malformed and prohibited MSIX versions' { + foreach ($invalid in @('1.2.3', '0.2.3.0', '1.2.3.4')) { + $config = New-ValidConfig + $config.msixVersion = $invalid + $config.previousMsixVersion = '' + { Assert-BusyMaxStoreConfig -Config $config -Mode ProductionStore } | + Should -Throw -ExpectedMessage "*msixVersion*ProductionStore*" + } + } + + It 'rejects non-HTTPS production links' { + $config = New-ValidConfig + $config.supportUrl = 'http://busystack.org/support' + { Assert-BusyMaxStoreConfig -Config $config -Mode ProductionStore } | + Should -Throw -ExpectedMessage "*supportUrl*ProductionStore*" + } + + It 'rejects fake data and development backends' { + $fake = New-ValidConfig + $fake.fakeData = $true + { Assert-BusyMaxStoreConfig -Config $fake -Mode ProductionStore } | + Should -Throw + $development = New-ValidConfig + $development.developmentBackend = $true + { Assert-BusyMaxStoreConfig -Config $development -Mode ProductionStore } | + Should -Throw + } + + It 'rejects a mismatched certificate publisher' { + { Assert-BusyMaxCertificatePublisher -CertificateSubject 'CN=Other' ` + -ManifestPublisher 'CN=BusyMax Owner' } | Should -Throw + { Assert-BusyMaxCertificatePublisher -CertificateSubject 'CN=BusyMax Owner' ` + -ManifestPublisher 'CN=BusyMax Owner' } | Should -Not -Throw + } +} diff --git a/tool/windows/tests/wack_report.Tests.ps1 b/tool/windows/tests/wack_report.Tests.ps1 new file mode 100644 index 00000000..42f7c7ef --- /dev/null +++ b/tool/windows/tests/wack_report.Tests.ps1 @@ -0,0 +1,63 @@ +BeforeAll { + . (Join-Path (Split-Path -Parent $PSScriptRoot) 'wack_report.ps1') +} + +Describe 'BusyMax WACK report parsing' { + BeforeEach { + $caseId = [guid]::NewGuid().ToString('N') + $report = Join-Path $TestDrive "$caseId-report.xml" + $dispositions = Join-Path $TestDrive "$caseId-dispositions.json" + } + + It 'accepts result elements and attribute-based test results' { + @' + + PASS + + +'@ | Set-Content -LiteralPath $report -Encoding utf8NoBOM + $parsed = Read-BusyMaxWackReport -ReportPath $report + $parsed.OverallResult | Should -Be 'PASS' + $parsed.ResultCount | Should -Be 2 + @($parsed.FailureNames).Count | Should -Be 0 + } + + It 'rejects every failed test even when the report overall value passes' { + @' + + + +'@ | Set-Content -LiteralPath $report -Encoding utf8NoBOM + $parsed = Read-BusyMaxWackReport -ReportPath $report + $parsed.FailureNames | Should -Contain 'Launch' + } + + It 'requires a non-placeholder disposition for every warning' { + @' + + WARNING + +'@ | Set-Content -LiteralPath $report -Encoding utf8NoBOM + $parsed = Read-BusyMaxWackReport -ReportPath $report + { Assert-BusyMaxWackWarningDispositions -Warnings $parsed.Warnings } | + Should -Throw + '{"Optional API":"TODO"}' | Set-Content -LiteralPath $dispositions ` + -Encoding utf8NoBOM + { Assert-BusyMaxWackWarningDispositions -Warnings $parsed.Warnings ` + -WarningDispositionPath $dispositions } | Should -Throw + '{"Optional API":"Reviewed; the optional API is not called by BusyMax."}' | + Set-Content -LiteralPath $dispositions -Encoding utf8NoBOM + { Assert-BusyMaxWackWarningDispositions -Warnings $parsed.Warnings ` + -WarningDispositionPath $dispositions } | Should -Not -Throw + } + + It 'rejects missing, empty, malformed, and result-free reports' { + { Read-BusyMaxWackReport -ReportPath $report } | Should -Throw + '' | Set-Content -LiteralPath $report -NoNewline + { Read-BusyMaxWackReport -ReportPath $report } | Should -Throw + '' | Set-Content -LiteralPath $report + { Read-BusyMaxWackReport -ReportPath $report } | Should -Throw + } +} diff --git a/tool/windows/validate_manifest.ps1 b/tool/windows/validate_manifest.ps1 new file mode 100644 index 00000000..7d36d7fa --- /dev/null +++ b/tool/windows/validate_manifest.ps1 @@ -0,0 +1,165 @@ +param([Parameter(Mandatory)][string]$ManifestPath) + +. "$PSScriptRoot/common.ps1" + +if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) { + throw "Manifest not found: $ManifestPath" +} +$manifestText = Get-Content -LiteralPath $ManifestPath -Raw +[xml]$manifest = $manifestText +$manager = [System.Xml.XmlNamespaceManager]::new($manifest.NameTable) +$manager.AddNamespace('f', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10') +$manager.AddNamespace('uap', 'http://schemas.microsoft.com/appx/manifest/uap/windows10') +$manager.AddNamespace('uap10', 'http://schemas.microsoft.com/appx/manifest/uap/windows10/10') +$manager.AddNamespace('desktop', 'http://schemas.microsoft.com/appx/manifest/desktop/windows10') +$manager.AddNamespace('desktop4', 'http://schemas.microsoft.com/appx/manifest/desktop/windows10/4') +$manager.AddNamespace('com', 'http://schemas.microsoft.com/appx/manifest/com/windows10') +$manager.AddNamespace('rescap', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities') + +$identity = $manifest.SelectSingleNode('/f:Package/f:Identity', $manager) +if ($null -eq $identity -or [string]::IsNullOrWhiteSpace($identity.Name) -or + [string]::IsNullOrWhiteSpace($identity.Publisher) -or + [string]::IsNullOrWhiteSpace($identity.Version)) { + throw 'MSIX identity values are incomplete.' +} +if ($identity.ProcessorArchitecture -ne 'x64') { throw 'MSIX architecture must be x64.' } +if ([string]$identity.Publisher -notmatch '^CN=.+') { + throw 'MSIX Publisher must be the exact Partner Center Publisher CN.' +} +if ([string]$identity.Version -notmatch '^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$') { + throw 'MSIX version must have four numeric components, start at 1 or newer, and end in 0.' +} +$versionParts = @($Matches[1], $Matches[2], $Matches[3], $Matches[4]) | + ForEach-Object { [uint64]$_ } +if ($versionParts[0] -lt 1 -or $versionParts[3] -ne 0 -or + @($versionParts | Where-Object { $_ -gt 65535 }).Count -gt 0) { + throw 'MSIX version must have components from 0 through 65535, start at 1 or newer, and end in 0.' +} +$target = $manifest.SelectSingleNode('/f:Package/f:Dependencies/f:TargetDeviceFamily', $manager) +if ($target.Name -ne 'Windows.Desktop' -or $target.MinVersion -ne '10.0.26100.0') { + throw 'MSIX must target Windows.Desktop 10.0.26100.0 or newer.' +} +$resources = @($manifest.SelectNodes('/f:Package/f:Resources/f:Resource', $manager)) +if ($resources.Count -eq 0) { throw 'MSIX resource language declarations are empty.' } +$resourceLanguages = @($resources | ForEach-Object { [string]$_.Language }) +if (($resourceLanguages | Sort-Object -Unique).Count -ne $resourceLanguages.Count) { + throw 'MSIX resource language declarations contain duplicates.' +} +foreach ($language in $resourceLanguages) { + if ($language -notmatch '^[A-Za-z]{2,3}(?:-[A-Za-z]{4})?(?:-[A-Za-z]{2}|-[0-9]{3})?$') { + throw "MSIX resource language is not a valid BCP-47 tag: $language" + } + try { + [Globalization.CultureInfo]::GetCultureInfo($language) | Out-Null + } catch { + throw "MSIX resource language is not recognized by Windows: $language" + } +} +$expectedResourceLanguages = @(Get-BusyMaxResourceLanguages) +$resourceDifference = Compare-Object ` + -ReferenceObject $expectedResourceLanguages ` + -DifferenceObject @($resourceLanguages | Sort-Object) +if ($resourceDifference) { + throw "MSIX resource languages differ from the supported ARB locales: $($resourceDifference.InputObject -join ', ')" +} +if ([version]$target.MaxVersionTested -lt [version]$target.MinVersion) { + throw 'MaxVersionTested must be at least the minimum Windows version.' +} +$applications = @($manifest.SelectNodes('/f:Package/f:Applications/f:Application', $manager)) +if ($applications.Count -ne 1) { throw 'MSIX must declare exactly one application.' } +$application = $applications[0] +if ($application.Id -cne 'BusyMax' -or $application.Executable -cne 'busymax.exe' -or + $application.EntryPoint -cne 'Windows.FullTrustApplication') { + throw 'The packaged application identity or executable is not the stable BusyMax value.' +} +$visualElements = $manifest.SelectSingleNode( + '/f:Package/f:Applications/f:Application/uap:VisualElements', $manager) +$propertiesLogo = $manifest.SelectSingleNode( + '/f:Package/f:Properties/f:Logo', $manager) +if ($null -eq $propertiesLogo -or + $propertiesLogo.InnerText -cne 'Assets\StoreLogo.png' -or + $null -eq $visualElements -or + $visualElements.Square44x44Logo -cne 'Assets\Square44x44Logo.png' -or + $visualElements.Square150x150Logo -cne 'Assets\Square150x150Logo.png') { + throw 'BusyMax package visual elements are incomplete.' +} +$fileType = $manifest.SelectSingleNode('//uap:FileType[text()=".ics"]', $manager) +$fileAssociationLogo = $manifest.SelectSingleNode( + '//uap:FileTypeAssociation/uap:Logo', $manager) +if ($null -eq $fileAssociationLogo -or + $fileAssociationLogo.InnerText -cne 'Assets\FileAssociationLogo.png') { + throw 'BusyMax file-association logo is incomplete.' +} +$protocol = $manifest.SelectSingleNode('//uap:Protocol[@Name="webcal"]', $manager) +$startup = $manifest.SelectSingleNode('//desktop:StartupTask[@TaskId="BusyMaxStartupTask"]', $manager) +$invalidDesktop4Toast = $manifest.SelectSingleNode( + '//desktop4:Extension[@Category="windows.toastNotificationActivation"]', + $manager) +if ($null -ne $invalidDesktop4Toast) { + throw 'Toast activation must use the base desktop namespace, not desktop4.' +} +$toastExtension = $manifest.SelectSingleNode( + '//desktop:Extension[@Category="windows.toastNotificationActivation"]', + $manager) +$toast = $null +if ($null -ne $toastExtension) { + $toast = $toastExtension.SelectSingleNode( + 'desktop:ToastNotificationActivation', $manager) +} +$toastServer = $manifest.SelectSingleNode('//com:ExeServer/com:Class', $manager) +if ($null -eq $fileType -or $null -eq $protocol -or $null -eq $startup -or + $null -eq $toastExtension -or $null -eq $toast -or + $null -eq $toastServer) { + throw 'Manifest activation declarations are incomplete.' +} +$extensions = @($manifest.SelectNodes( + '/f:Package/f:Applications/f:Application/f:Extensions/*', $manager)) +$extensionCategories = @($extensions | ForEach-Object { $_.Category }) +$expectedExtensionCategories = @( + 'windows.fileTypeAssociation', 'windows.protocol', 'windows.startupTask', + 'windows.toastNotificationActivation', 'windows.comServer' +) +if ($extensions.Count -ne $expectedExtensionCategories.Count -or + @($extensionCategories | Where-Object { + $_ -notin $expectedExtensionCategories + }).Count -ne 0 -or + @($expectedExtensionCategories | Where-Object { + $_ -notin $extensionCategories + }).Count -ne 0) { + throw "Manifest extensions differ from the BusyMax allowlist: $($extensionCategories -join ', ')" +} +$registeredFileTypes = @($manifest.SelectNodes('//uap:SupportedFileTypes/uap:FileType', $manager)) +$registeredProtocols = @($manifest.SelectNodes('//uap:Protocol', $manager)) +if ($registeredFileTypes.Count -ne 1 -or $registeredFileTypes[0].InnerText -cne '.ics' -or + $registeredProtocols.Count -ne 1 -or $registeredProtocols[0].Name -cne 'webcal') { + throw 'BusyMax may register only the .ics file type and webcal protocol.' +} +$startupExtension = $startup.ParentNode +$startupArguments = $startupExtension.GetAttribute( + 'Parameters', 'http://schemas.microsoft.com/appx/manifest/uap/windows10/10') +if ($startup.Enabled -ne 'false' -or $startupArguments -ne '--start-minimized') { + throw 'StartupTask must be disabled by default and start minimized.' +} +if ($toast.ToastActivatorCLSID -cne '7B854A6D-8B2A-45A5-B998-1F51EC5A81D7') { + throw 'Toast activator CLSID does not match the committed notification backend.' +} +if ($toastExtension.NamespaceURI -cne 'http://schemas.microsoft.com/appx/manifest/desktop/windows10' -or + $toast.NamespaceURI -cne 'http://schemas.microsoft.com/appx/manifest/desktop/windows10') { + throw 'Toast activation must use desktop:Extension and desktop:ToastNotificationActivation.' +} +if ($toastServer.Id -cne $toast.ToastActivatorCLSID -or + $toastServer.ParentNode.Arguments -ne '----AppNotificationActivationServer') { + throw 'Toast COM server does not match the activation declaration.' +} +$capabilities = @($manifest.SelectNodes('/f:Package/f:Capabilities/*', $manager) | + ForEach-Object { $_.Name }) +if (@($capabilities | Where-Object { $_ -notin @('internetClient', 'runFullTrust') }).Count -ne 0) { + throw "Manifest contains an unexpected capability: $($capabilities -join ', ')" +} +foreach ($required in @('internetClient', 'runFullTrust')) { + if ($required -notin $capabilities) { throw "Missing capability: $required" } +} +if ($manifestText -match '@@[A-Z_]+@@|(?i)REPLACE_WITH|example\.com|placeholder|\bTODO\b|your[-_]') { + throw 'Manifest placeholders remain.' +} +Write-Host "Validated manifest: $ManifestPath" diff --git a/tool/windows/validate_package_contents.ps1 b/tool/windows/validate_package_contents.ps1 new file mode 100644 index 00000000..c3ecb8cc --- /dev/null +++ b/tool/windows/validate_package_contents.ps1 @@ -0,0 +1,174 @@ +param( + [Parameter(Mandatory)][Alias('StagingPath')][string]$PackageRoot, + [string]$InventoryOutputPath = '', + [ValidateSet('Staging', 'FinalMsix')][string]$ValidationMode = 'Staging' +) + +. "$PSScriptRoot/common.ps1" + +if ($ValidationMode -eq 'FinalMsix') { + Assert-BusyMaxFinalMsixMetadata -PackageRoot $PackageRoot +} +$packageFiles = @(Get-BusyMaxPackageFiles -PackageRoot $PackageRoot) + +if (-not (Test-Path -LiteralPath "$PackageRoot/busymax.exe")) { + throw 'Staging directory does not contain busymax.exe.' +} +foreach ($required in @( + 'flutter_windows.dll', 'sqlite3.dll', + 'connectivity_plus_plugin.dll', 'file_selector_windows_plugin.dll', + 'flutter_local_notifications_windows.dll', + 'flutter_secure_storage_windows_plugin.dll', + 'flutter_timezone_plugin.dll', 'system_theme_plugin.dll', + 'tray_manager_plugin.dll', 'url_launcher_windows_plugin.dll', + 'msvcp140.dll', 'vcruntime140.dll', 'vcruntime140_1.dll', + 'data/app.so', 'data/icudtl.dat', + 'data/flutter_assets/AssetManifest.bin', + 'AppxManifest.xml')) { + if (-not (Test-Path -LiteralPath (Join-Path $PackageRoot $required))) { + throw "Required package file is missing: $required" + } +} +$allowedAssets = @( + 'Assets/StoreLogo.png', + 'Assets/Square44x44Logo.png', + 'Assets/Square150x150Logo.png', + 'Assets/FileAssociationLogo.png') +foreach ($asset in $allowedAssets) { + if (-not (Test-Path -LiteralPath (Join-Path $PackageRoot $asset))) { + throw "Required unqualified package visual asset is missing: $asset" + } +} +$unexpectedAssets = @($packageFiles | + Where-Object { + $relative = Get-BusyMaxPackageRelativePath -PackageRoot $PackageRoot ` + -Path $_.FullName + $relative.StartsWith('Assets/', [StringComparison]::OrdinalIgnoreCase) -and + $relative -notin $allowedAssets + }) +if ($unexpectedAssets.Count -gt 0) { + throw "Unexpected or unreferenced package visual asset: $($unexpectedAssets.FullName -join ', ')" +} +$qualifiedVisualAssets = @($packageFiles | Where-Object { + $relative = Get-BusyMaxPackageRelativePath -PackageRoot $PackageRoot ` + -Path $_.FullName + $relative.StartsWith('Assets/', [StringComparison]::OrdinalIgnoreCase) -and + $_.Name -match '(?i)\.(scale|targetsize)-' + }) +if ($qualifiedVisualAssets.Count -gt 0) { + throw 'Qualified visual assets require a generated resources.pri and must not be staged by the unqualified-asset packaging path.' +} +if (Test-Path -LiteralPath (Join-Path $PackageRoot 'resources.pri')) { + throw 'The unqualified-asset packaging path must not contain a stale resources.pri.' +} +Assert-BusyMaxPackageHasNoProhibitedFiles -PackageRoot $PackageRoot +$executables = @($packageFiles | Where-Object { $_.Extension -ieq '.exe' }) +if ($executables.Count -ne 1 -or $executables[0].Name -cne 'busymax.exe') { + throw "The package must contain only busymax.exe: $($executables.FullName -join ', ')" +} +$allowedDlls = @( + 'flutter_windows.dll', 'sqlite3.dll', + 'connectivity_plus_plugin.dll', 'file_selector_windows_plugin.dll', + 'flutter_local_notifications_windows.dll', + 'flutter_secure_storage_windows_plugin.dll', + 'flutter_timezone_plugin.dll', 'system_theme_plugin.dll', + 'tray_manager_plugin.dll', 'url_launcher_windows_plugin.dll', + 'concrt140.dll', 'msvcp140.dll', 'msvcp140_1.dll', 'msvcp140_2.dll', + 'msvcp140_atomic_wait.dll', 'msvcp140_codecvt_ids.dll', 'vccorlib140.dll', + 'vcruntime140.dll', 'vcruntime140_1.dll', 'vcruntime140_threads.dll' +) +$unexpectedDlls = @($packageFiles | Where-Object { $_.Extension -ieq '.dll' } | + Where-Object { + $relative = Get-BusyMaxPackageRelativePath -PackageRoot $PackageRoot ` + -Path $_.FullName + $_.Name -notin $allowedDlls -or $relative -cne $_.Name + }) +if ($unexpectedDlls) { + throw "Unexpected package DLLs: $($unexpectedDlls.FullName -join ', ')" +} +function Assert-BusyMaxX64Pe { + param([Parameter(Mandatory)][string]$Path) + $bytes = [IO.File]::ReadAllBytes($Path) + if ($bytes.Length -lt 64 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) { + throw "Packaged native binary is not a Windows PE file: $Path" + } + $peOffset = [BitConverter]::ToInt32($bytes, 0x3c) + if ($peOffset -lt 0 -or $peOffset + 6 -gt $bytes.Length -or + $bytes[$peOffset] -ne 0x50 -or $bytes[$peOffset + 1] -ne 0x45 -or + $bytes[$peOffset + 2] -ne 0 -or $bytes[$peOffset + 3] -ne 0 -or + [BitConverter]::ToUInt16($bytes, $peOffset + 4) -ne 0x8664) { + throw "Packaged native binary is not x64 PE: $Path" + } +} +foreach ($nativeBinary in @( + Get-ChildItem -LiteralPath $PackageRoot -File -Filter '*.exe' -Force + Get-ChildItem -LiteralPath $PackageRoot -File -Filter '*.dll' -Force + )) { + Assert-BusyMaxX64Pe -Path $nativeBinary.FullName +} +function Assert-BusyMaxX64Elf { + param([Parameter(Mandatory)][string]$Path) + $bytes = [IO.File]::ReadAllBytes($Path) + if ($bytes.Length -lt 20 -or + $bytes[0] -ne 0x7f -or $bytes[1] -ne 0x45 -or + $bytes[2] -ne 0x4c -or $bytes[3] -ne 0x46 -or + $bytes[4] -ne 2 -or $bytes[5] -ne 1 -or + [BitConverter]::ToUInt16($bytes, 18) -ne 0x3e) { + throw "Flutter AOT artifact is not a little-endian x64 ELF file: $Path" + } +} +Assert-BusyMaxX64Elf -Path ` + (Join-Path $PackageRoot 'data/app.so') +$manifestPath = Join-Path $PackageRoot 'AppxManifest.xml' +& "$PSScriptRoot/validate_manifest.ps1" -ManifestPath $manifestPath +[xml]$packageManifest = Get-Content -LiteralPath $manifestPath -Raw +$manifestNamespaces = [System.Xml.XmlNamespaceManager]::new( + $packageManifest.NameTable) +$manifestNamespaces.AddNamespace( + 'f', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10') +$manifestNamespaces.AddNamespace( + 'uap', 'http://schemas.microsoft.com/appx/manifest/uap/windows10') +$propertiesLogo = $packageManifest.SelectSingleNode( + '/f:Package/f:Properties/f:Logo', $manifestNamespaces) +$visualElements = $packageManifest.SelectSingleNode( + '//uap:VisualElements', $manifestNamespaces) +$fileAssociationLogo = $packageManifest.SelectSingleNode( + '//uap:FileTypeAssociation/uap:Logo', $manifestNamespaces) +if ($null -eq $propertiesLogo -or $null -eq $visualElements -or + $null -eq $fileAssociationLogo) { + throw 'The exact package manifest is missing required logo declarations.' +} +$logoReferences = @( + [string]$propertiesLogo.InnerText + [string]$visualElements.Square44x44Logo + [string]$visualElements.Square150x150Logo + [string]$fileAssociationLogo.InnerText +) | Sort-Object -Unique +if ($logoReferences.Count -ne 4) { + throw 'The manifest must contain exactly four distinct BusyMax logo paths.' +} +foreach ($reference in $logoReferences) { + if ([string]::IsNullOrWhiteSpace($reference) -or + [IO.Path]::IsPathRooted($reference) -or + $reference -match '(^|[\\/])\.\.([\\/]|$)') { + throw "Manifest logo path is unsafe: $reference" + } + $resolvedReference = $reference.Replace( + '\', [IO.Path]::DirectorySeparatorChar) + if (-not (Test-Path -LiteralPath ` + (Join-Path $PackageRoot $resolvedReference) -PathType Leaf)) { + throw "Manifest logo does not resolve inside the exact package: $reference" + } +} + +$inventory = @(Get-BusyMaxPackageInventory -PackageRoot $PackageRoot) +if (-not [string]::IsNullOrWhiteSpace($InventoryOutputPath)) { + $inventoryDirectory = Split-Path -Parent $InventoryOutputPath + if (-not [string]::IsNullOrWhiteSpace($inventoryDirectory)) { + New-Item -ItemType Directory -Force -Path $inventoryDirectory | Out-Null + } + $inventory | ConvertTo-Json -Depth 3 | Set-Content ` + -LiteralPath $InventoryOutputPath -Encoding utf8NoBOM +} +Write-Host "Validated package contents: $PackageRoot" +$inventory diff --git a/tool/windows/validate_release_config.ps1 b/tool/windows/validate_release_config.ps1 new file mode 100644 index 00000000..002c2091 --- /dev/null +++ b/tool/windows/validate_release_config.ps1 @@ -0,0 +1,16 @@ +param([Parameter(Mandatory)][string]$ConfigPath, [switch]$Ci) + +. "$PSScriptRoot/common.ps1" +$config = Get-BusyMaxStoreConfig -Path $ConfigPath +$mode = if ($Ci) { 'CiNonProduction' } else { 'ProductionStore' } +Assert-BusyMaxStoreConfig -Config $config -Mode $mode +$familyName = Get-BusyMaxPackageFamilyName ` + -Name $config.identityName -Publisher $config.publisher +[pscustomobject]@{ + IdentityName = $config.identityName + Publisher = $config.publisher + Version = $config.msixVersion + PackageFamilyName = $familyName + AppUserModelId = "$familyName!BusyMax" + Production = $config.production +} diff --git a/tool/windows/wack_report.ps1 b/tool/windows/wack_report.ps1 new file mode 100644 index 00000000..0d660024 --- /dev/null +++ b/tool/windows/wack_report.ps1 @@ -0,0 +1,131 @@ +Set-StrictMode -Version Latest + +function Get-BusyMaxWackAttribute { + param( + [Parameter(Mandatory)][System.Xml.XmlNode]$Node, + [Parameter(Mandatory)][string[]]$Names + ) + foreach ($name in $Names) { + $attribute = $Node.Attributes[$name] + if ($null -ne $attribute -and + -not [string]::IsNullOrWhiteSpace([string]$attribute.Value)) { + return ([string]$attribute.Value).Trim() + } + } + return '' +} + +function Get-BusyMaxWackNodeName { + param( + [Parameter(Mandatory)][System.Xml.XmlNode]$Node, + [Parameter(Mandatory)][string]$Fallback + ) + $named = $Node.SelectSingleNode('ancestor-or-self::*[@NAME or @Name or @TITLE or @Title][1]') + if ($null -eq $named) { return $Fallback } + $name = Get-BusyMaxWackAttribute -Node $named ` + -Names @('NAME', 'Name', 'TITLE', 'Title') + if ([string]::IsNullOrWhiteSpace($name)) { return $Fallback } + return $name +} + +function Read-BusyMaxWackReport { + param([Parameter(Mandatory)][string]$ReportPath) + if (-not (Test-Path -LiteralPath $ReportPath -PathType Leaf)) { + throw 'WACK did not emit an XML report.' + } + if ((Get-Item -LiteralPath $ReportPath).Length -eq 0) { + throw 'WACK emitted an empty XML report.' + } + try { + [xml]$report = Get-Content -LiteralPath $ReportPath -Raw + } catch { + throw "WACK emitted malformed XML: $($_.Exception.Message)" + } + $root = $report.DocumentElement + if ($null -eq $root -or $root.LocalName -cne 'REPORT') { + throw 'WACK XML does not contain the expected REPORT root.' + } + $overall = Get-BusyMaxWackAttribute -Node $root ` + -Names @('OVERALL_RESULT', 'OverallResult', 'RESULT', 'Result') + $overall = $overall.ToUpperInvariant() + if ([string]::IsNullOrWhiteSpace($overall)) { + throw 'WACK XML does not contain OVERALL_RESULT.' + } + + $recognizedValues = @('PASS', 'PASSED', 'FAIL', 'FAILED', 'ERROR', 'WARNING', 'WARN') + $records = @() + foreach ($node in @($report.SelectNodes('//*'))) { + if ($node -eq $root) { continue } + $value = '' + if ($node.LocalName -ceq 'RESULT') { + $value = ([string]$node.InnerText).Trim() + } + if ([string]::IsNullOrWhiteSpace($value)) { + $value = Get-BusyMaxWackAttribute -Node $node ` + -Names @('RESULT', 'Result', 'STATUS', 'Status', 'OUTCOME', 'Outcome', 'OVERALL_RESULT', 'OverallResult') + } + $value = $value.ToUpperInvariant() + if ($value -in $recognizedValues) { + $records += [pscustomobject]@{ Node = $node; Value = $value } + } + } + if ($records.Count -eq 0) { + throw 'WACK XML contains no recognized per-test results.' + } + + $failureNames = @($records | Where-Object { + $_.Value -in @('FAIL', 'FAILED', 'ERROR') + } | ForEach-Object { + Get-BusyMaxWackNodeName -Node $_.Node -Fallback $_.Value + } | Sort-Object -Unique) + + $warningNames = @($records | Where-Object { + $_.Value -in @('WARNING', 'WARN') + } | ForEach-Object { + Get-BusyMaxWackNodeName -Node $_.Node -Fallback 'WACK warning' + }) + foreach ($node in @($report.SelectNodes('//*'))) { + $severity = Get-BusyMaxWackAttribute -Node $node ` + -Names @('TYPE', 'Type', 'SEVERITY', 'Severity', 'MESSAGE_TYPE', 'MessageType') + if ($node.LocalName.ToUpperInvariant() -in @('WARNING', 'WARN') -or + $severity.ToUpperInvariant() -in @('WARNING', 'WARN')) { + $warningNames += Get-BusyMaxWackNodeName -Node $node ` + -Fallback 'WACK warning' + } + } + $warningNames = @($warningNames | Sort-Object -Unique) + + return [pscustomobject]@{ + OverallResult = $overall + ResultCount = $records.Count + FailureNames = $failureNames + Warnings = $warningNames + } +} + +function Assert-BusyMaxWackWarningDispositions { + param( + [Parameter(Mandatory)][string[]]$Warnings, + [string]$WarningDispositionPath = '' + ) + if ($Warnings.Count -gt 0) { + if ([string]::IsNullOrWhiteSpace($WarningDispositionPath) -or + -not (Test-Path -LiteralPath $WarningDispositionPath -PathType Leaf)) { + throw 'WACK reported warnings. Supply -WarningDispositionPath with a JSON object containing a written disposition for every warning name.' + } + try { + $dispositions = Get-Content -LiteralPath $WarningDispositionPath -Raw | + ConvertFrom-Json + } catch { + throw "WACK warning dispositions are malformed JSON: $($_.Exception.Message)" + } + foreach ($warningName in $Warnings) { + $property = $dispositions.PSObject.Properties[$warningName] + if ($null -eq $property -or + [string]::IsNullOrWhiteSpace([string]$property.Value) -or + [string]$property.Value -match '(?i)(todo|placeholder|replace_with)') { + throw "WACK warning '$warningName' has no explicit written disposition." + } + } + } +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 00000000..d492d0d9 --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 00000000..a305a965 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,114 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(busymax LANGUAGES CXX) +enable_testing() + +if(NOT CMAKE_SIZEOF_VOID_P EQUAL 8 OR CMAKE_GENERATOR_PLATFORM STREQUAL "ARM64") + message(FATAL_ERROR "BusyMax for Windows supports x64 only.") +endif() + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "busymax") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) +add_definitions(-DWINVER=0x0A00 -D_WIN32_WINNT=0x0A00) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 00000000..903f4899 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..d75d682d --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,32 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + FlutterTimezonePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi")); + SystemThemePluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SystemThemePlugin")); + TrayManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("TrayManagerPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..dc139d85 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 00000000..9179dc1e --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,32 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus + file_selector_windows + flutter_secure_storage_windows + flutter_timezone + system_theme + tray_manager + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 00000000..1bdd7b80 --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,60 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "single_instance.cpp" + "utils.cpp" + "windows_startup_task.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Flutter 3.44.4 targets C++17. C++/WinRT therefore uses the legacy coroutine +# header, which current MSVC toolsets require consumers to acknowledge. +target_compile_definitions(${BINARY_NAME} PRIVATE + "_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_link_libraries(${BINARY_NAME} PRIVATE "advapi32.lib" "windowsapp.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Built and executed explicitly by tool/windows/run_native_tests.ps1. Keeping +# it out of the default target prevents test code from entering the package. +add_executable(busymax_runner_native_test EXCLUDE_FROM_ALL + "tests/single_instance_test.cpp" + "single_instance.cpp" +) +apply_standard_settings(busymax_runner_native_test) +target_compile_definitions(busymax_runner_native_test PRIVATE "NOMINMAX") +target_link_libraries(busymax_runner_native_test PRIVATE + "advapi32.lib" "user32.lib") +add_test(NAME busymax_runner_native_test COMMAND busymax_runner_native_test) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 00000000..2f87e6a4 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "BusyStack" "\0" + VALUE "FileDescription", "BusyMax calendar and task manager" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "busymax" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 BusyStack. All rights reserved." "\0" + VALUE "OriginalFilename", "busymax.exe" "\0" + VALUE "ProductName", "BusyMax" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 00000000..cae08c35 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,293 @@ +#include "flutter_window.h" + +#include +#include +#include +#include +#include + +#include "flutter/generated_plugin_registrant.h" +#include "windows_startup_task.h" + +namespace { + +constexpr char kDesktopChannel[] = + "org.busystack.busymax/windows_desktop"; +constexpr UINT kActivationMessage = WM_APP + 42; +constexpr UINT kUiTaskMessage = WM_APP + 43; +constexpr LONG kMinimumWidth = 900; +constexpr LONG kMinimumHeight = 600; + +bool HasPackageIdentity() { + UINT32 length = 0; + const LONG result = GetCurrentPackageFullName(&length, nullptr); + return result == ERROR_INSUFFICIENT_BUFFER && length > 0; +} + +void PostUiTask(HWND window, std::function task) { + auto* owned = new std::function(std::move(task)); + if (!PostMessage(window, kUiTaskMessage, 0, + reinterpret_cast(owned))) { + delete owned; + } +} + +} // namespace + +FlutterWindow::FlutterWindow(const flutter::DartProject& project, + std::vector initial_activations, + bool start_hidden, + std::function + forward_activation) + : project_(project), + pending_activations_(std::move(initial_activations)), + start_hidden_(start_hidden), + forward_activation_(std::move(forward_activation)) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + desktop_channel_ = + std::make_unique>( + flutter_controller_->engine()->messenger(), kDesktopChannel, + &flutter::StandardMethodCodec::GetInstance()); + desktop_channel_->SetMethodCallHandler( + [this](const auto& call, auto result) { + const auto& method = call.method_name(); + if (method == "hide") { + ShowWindow(GetHandle(), SW_HIDE); + result->Success(); + return; + } + if (method == "showAndFocus") { + ShowWindow(GetHandle(), IsIconic(GetHandle()) ? SW_RESTORE : SW_SHOW); + SetForegroundWindow(GetHandle()); + if (flutter_controller_ && flutter_controller_->view()) { + SetFocus(flutter_controller_->view()->GetNativeWindow()); + } + result->Success(); + return; + } + if (method == "isVisible") { + result->Success(flutter::EncodableValue( + static_cast(IsWindowVisible(GetHandle())))); + return; + } + if (method == "hasPackageIdentity") { + result->Success(flutter::EncodableValue(HasPackageIdentity())); + return; + } + if (method == "reportNotificationActivationFailure") { + MessageBoxW( + GetHandle(), + L"BusyMax could not deliver the selected notification action " + L"to the running application. No calendar or task data was " + L"changed by this process.", + L"BusyMax notification error", MB_OK | MB_ICONERROR); + result->Success(); + return; + } + if (method == "setHideOnClose") { + const auto* enabled = + std::get_if(call.arguments()); + if (enabled == nullptr) { + result->Error("invalid_argument", "Expected a Boolean value."); + return; + } + hide_on_close_ = *enabled; + result->Success(); + return; + } + if (method == "quit") { + quit_requested_ = true; + result->Success(); + PostMessage(GetHandle(), WM_CLOSE, 0, 0); + return; + } + if (method == "takeInitialActivations") { + flutter::EncodableList encoded; + { + std::scoped_lock lock(activation_mutex_); + for (const auto& activation : pending_activations_) { + encoded.emplace_back(activation); + } + pending_activations_.clear(); + } + result->Success(flutter::EncodableValue(encoded)); + return; + } + if (method == "activationReady") { + activation_ready_ = true; + std::vector queued; + { + std::scoped_lock lock(activation_mutex_); + queued.swap(pending_activations_); + } + for (const auto& activation : queued) { + desktop_channel_->InvokeMethod( + "activation", + std::make_unique(activation)); + } + result->Success(); + return; + } + if (method == "forwardActivation") { + const auto* activation = + std::get_if(call.arguments()); + if (activation == nullptr || !forward_activation_) { + result->Error("invalid_argument", + "Expected an encoded BusyMax activation."); + return; + } + result->Success( + flutter::EncodableValue(forward_activation_(*activation))); + return; + } + if (method == "getStartupTaskState") { + const auto window = GetHandle(); + auto shared_result = + std::shared_ptr>( + std::move(result)); + GetBusyMaxStartupTaskStateAsync( + [window, shared_result](std::string state) { + PostUiTask( + window, + [shared_result, state = std::move(state)]() { + shared_result->Success(flutter::EncodableValue(state)); + }); + }); + return; + } + if (method == "setStartupTaskEnabled") { + const auto* enabled = std::get_if(call.arguments()); + if (enabled == nullptr) { + result->Error("invalid_argument", "Expected a Boolean value."); + return; + } + const auto window = GetHandle(); + auto shared_result = + std::shared_ptr>( + std::move(result)); + SetBusyMaxStartupTaskEnabledAsync( + *enabled, + [window, shared_result](bool succeeded, std::string error) { + PostUiTask( + window, + [shared_result, succeeded, error = std::move(error)]() { + if (succeeded) { + shared_result->Success(); + } else { + shared_result->Error("startup_task_failed", error); + } + }); + }); + return; + } + result->NotImplemented(); + }); + + flutter_controller_->engine()->SetNextFrameCallback([this]() { + if (!start_hidden_) Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::QueueActivation(std::string activation) { + if (GetHandle() == nullptr) { + std::scoped_lock lock(activation_mutex_); + pending_activations_.push_back(std::move(activation)); + return; + } + auto* owned = new std::string(std::move(activation)); + if (!PostMessage(GetHandle(), kActivationMessage, 0, + reinterpret_cast(owned))) { + delete owned; + } +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_CLOSE: + if (!quit_requested_ && hide_on_close_) { + ShowWindow(hwnd, SW_HIDE); + return 0; + } + break; + case WM_GETMINMAXINFO: { + const UINT dpi = GetDpiForWindow(hwnd); + auto* sizing = reinterpret_cast(lparam); + sizing->ptMinTrackSize.x = MulDiv(kMinimumWidth, dpi, 96); + sizing->ptMinTrackSize.y = MulDiv(kMinimumHeight, dpi, 96); + return 0; + } + case kActivationMessage: { + std::unique_ptr activation( + reinterpret_cast(lparam)); + if (!activation_ready_ || desktop_channel_ == nullptr) { + std::scoped_lock lock(activation_mutex_); + pending_activations_.push_back(std::move(*activation)); + } else { + desktop_channel_->InvokeMethod( + "activation", std::make_unique(*activation)); + } + return 0; + } + case kUiTaskMessage: { + std::unique_ptr> task( + reinterpret_cast*>(lparam)); + (*task)(); + return 0; + } + case WM_FONTCHANGE: + if (flutter_controller_) { + flutter_controller_->engine()->ReloadSystemFonts(); + } + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 00000000..d7ad6fbf --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,52 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + FlutterWindow(const flutter::DartProject& project, + std::vector initial_activations, + bool start_hidden, + std::function forward_activation); + virtual ~FlutterWindow(); + + void QueueActivation(std::string activation); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; + std::unique_ptr> + desktop_channel_; + std::mutex activation_mutex_; + std::vector pending_activations_; + bool activation_ready_ = false; + bool hide_on_close_ = false; + bool quit_requested_ = false; + bool start_hidden_ = false; + std::function forward_activation_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 00000000..168b00bb --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,123 @@ +#include +#include +#include + +#include +#include + +#include "flutter_window.h" +#include "single_instance.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + std::vector command_line_arguments = + GetCommandLineArguments(); + const bool notification_activation_server = + std::find(command_line_arguments.begin(), command_line_arguments.end(), + "----AppNotificationActivationServer") != + command_line_arguments.end(); + const std::string initial_activation = + BuildBusyMaxActivation(command_line_arguments); + BusyMaxSingleInstance single_instance; + if (single_instance.state() == + BusyMaxInstanceState::kFatalInitializationFailure) { + MessageBoxW(nullptr, + L"BusyMax could not initialize its per-user single-instance " + L"guard. No application data was opened.", + L"BusyMax startup error", MB_OK | MB_ICONERROR); + return EXIT_FAILURE; + } + const bool notification_forwarder = + single_instance.state() == BusyMaxInstanceState::kSecondary && + notification_activation_server; + if (single_instance.state() == BusyMaxInstanceState::kSecondary && + !notification_forwarder) { + if (!single_instance.ForwardActivation(initial_activation)) { + MessageBoxW(nullptr, + L"BusyMax could not contact the running instance. No " + L"application data was opened by this process.", + L"BusyMax activation error", MB_OK | MB_ICONERROR); + return EXIT_FAILURE; + } + return EXIT_SUCCESS; + } + if (notification_forwarder) { + // The notification package receives COM activation only after its Dart FFI + // callback server starts. This private mode starts no BusyMax providers or + // database; it validates and forwards the resulting activation, then exits. + command_line_arguments.emplace_back("--busymax-notification-forwarder"); + } + + // Initialize COM, so that it is available for plugins and packaged Windows + // APIs such as StartupTask. + const HRESULT com_result = + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (FAILED(com_result)) { + MessageBoxW(nullptr, L"BusyMax could not initialize Windows COM services.", + L"BusyMax startup error", MB_OK | MB_ICONERROR); + return EXIT_FAILURE; + } + + flutter::DartProject project(L"data"); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + const bool start_hidden = + initial_activation.find("\"kind\":\"startMinimized\"") != + std::string::npos; + FlutterWindow window( + project, {initial_activation}, start_hidden, + [&single_instance](const std::string& activation) { + return single_instance.ForwardActivation(activation); + }); + const DWORD main_thread_id = GetCurrentThreadId(); + // Create the main-thread message queue before the listener can report a + // fatal post-start failure from its worker thread. + MSG pending_message{}; + PeekMessageW(&pending_message, nullptr, WM_USER, WM_USER, PM_NOREMOVE); + if (single_instance.IsPrimary()) { + if (!single_instance.Start([&window](std::string activation) { + window.QueueActivation(std::move(activation)); + }, [main_thread_id] { + PostThreadMessageW(main_thread_id, WM_QUIT, EXIT_FAILURE, 0); + })) { + MessageBoxW(nullptr, + L"BusyMax could not start its secured activation channel. " + L"No application data was opened.", + L"BusyMax startup error", MB_OK | MB_ICONERROR); + ::CoUninitialize(); + return EXIT_FAILURE; + } + } + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 800); + if (!window.Create(L"BusyMax", origin, size)) { + single_instance.Stop(); + ::CoUninitialize(); + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg{}; + BOOL message_result = 0; + while ((message_result = ::GetMessage(&msg, nullptr, 0, 0)) > 0) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + const int process_result = message_result == -1 || msg.wParam != 0 + ? EXIT_FAILURE + : EXIT_SUCCESS; + + single_instance.Stop(); + if (SUCCEEDED(com_result)) { + ::CoUninitialize(); + } + return process_result; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 00000000..66a65d1e --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 00000000..0dc7487a Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/resources/msix/FileAssociation.png b/windows/runner/resources/msix/FileAssociation.png new file mode 100644 index 00000000..d0c2947a Binary files /dev/null and b/windows/runner/resources/msix/FileAssociation.png differ diff --git a/windows/runner/resources/msix/FileAssociation.scale-100.png b/windows/runner/resources/msix/FileAssociation.scale-100.png new file mode 100644 index 00000000..d0c2947a Binary files /dev/null and b/windows/runner/resources/msix/FileAssociation.scale-100.png differ diff --git a/windows/runner/resources/msix/FileAssociation.scale-125.png b/windows/runner/resources/msix/FileAssociation.scale-125.png new file mode 100644 index 00000000..85a96df4 Binary files /dev/null and b/windows/runner/resources/msix/FileAssociation.scale-125.png differ diff --git a/windows/runner/resources/msix/FileAssociation.scale-150.png b/windows/runner/resources/msix/FileAssociation.scale-150.png new file mode 100644 index 00000000..1fc16821 Binary files /dev/null and b/windows/runner/resources/msix/FileAssociation.scale-150.png differ diff --git a/windows/runner/resources/msix/FileAssociation.scale-200.png b/windows/runner/resources/msix/FileAssociation.scale-200.png new file mode 100644 index 00000000..6e227e0d Binary files /dev/null and b/windows/runner/resources/msix/FileAssociation.scale-200.png differ diff --git a/windows/runner/resources/msix/FileAssociation.scale-400.png b/windows/runner/resources/msix/FileAssociation.scale-400.png new file mode 100644 index 00000000..cd04708d Binary files /dev/null and b/windows/runner/resources/msix/FileAssociation.scale-400.png differ diff --git a/windows/runner/resources/msix/FileAssociationLogo.png b/windows/runner/resources/msix/FileAssociationLogo.png new file mode 100644 index 00000000..d0c2947a Binary files /dev/null and b/windows/runner/resources/msix/FileAssociationLogo.png differ diff --git a/windows/runner/resources/msix/Square150x150Logo.png b/windows/runner/resources/msix/Square150x150Logo.png new file mode 100644 index 00000000..a153498a Binary files /dev/null and b/windows/runner/resources/msix/Square150x150Logo.png differ diff --git a/windows/runner/resources/msix/Square150x150Logo.scale-100.png b/windows/runner/resources/msix/Square150x150Logo.scale-100.png new file mode 100644 index 00000000..a153498a Binary files /dev/null and b/windows/runner/resources/msix/Square150x150Logo.scale-100.png differ diff --git a/windows/runner/resources/msix/Square150x150Logo.scale-125.png b/windows/runner/resources/msix/Square150x150Logo.scale-125.png new file mode 100644 index 00000000..367eafca Binary files /dev/null and b/windows/runner/resources/msix/Square150x150Logo.scale-125.png differ diff --git a/windows/runner/resources/msix/Square150x150Logo.scale-150.png b/windows/runner/resources/msix/Square150x150Logo.scale-150.png new file mode 100644 index 00000000..8acc4b76 Binary files /dev/null and b/windows/runner/resources/msix/Square150x150Logo.scale-150.png differ diff --git a/windows/runner/resources/msix/Square150x150Logo.scale-200.png b/windows/runner/resources/msix/Square150x150Logo.scale-200.png new file mode 100644 index 00000000..ed2ea006 Binary files /dev/null and b/windows/runner/resources/msix/Square150x150Logo.scale-200.png differ diff --git a/windows/runner/resources/msix/Square150x150Logo.scale-400.png b/windows/runner/resources/msix/Square150x150Logo.scale-400.png new file mode 100644 index 00000000..bec3d9ab Binary files /dev/null and b/windows/runner/resources/msix/Square150x150Logo.scale-400.png differ diff --git a/windows/runner/resources/msix/Square44x44Logo.png b/windows/runner/resources/msix/Square44x44Logo.png new file mode 100644 index 00000000..d0c2947a Binary files /dev/null and b/windows/runner/resources/msix/Square44x44Logo.png differ diff --git a/windows/runner/resources/msix/Square44x44Logo.scale-100.png b/windows/runner/resources/msix/Square44x44Logo.scale-100.png new file mode 100644 index 00000000..d0c2947a Binary files /dev/null and b/windows/runner/resources/msix/Square44x44Logo.scale-100.png differ diff --git a/windows/runner/resources/msix/Square44x44Logo.scale-125.png b/windows/runner/resources/msix/Square44x44Logo.scale-125.png new file mode 100644 index 00000000..85a96df4 Binary files /dev/null and b/windows/runner/resources/msix/Square44x44Logo.scale-125.png differ diff --git a/windows/runner/resources/msix/Square44x44Logo.scale-150.png b/windows/runner/resources/msix/Square44x44Logo.scale-150.png new file mode 100644 index 00000000..1fc16821 Binary files /dev/null and b/windows/runner/resources/msix/Square44x44Logo.scale-150.png differ diff --git a/windows/runner/resources/msix/Square44x44Logo.scale-200.png b/windows/runner/resources/msix/Square44x44Logo.scale-200.png new file mode 100644 index 00000000..6e227e0d Binary files /dev/null and b/windows/runner/resources/msix/Square44x44Logo.scale-200.png differ diff --git a/windows/runner/resources/msix/Square44x44Logo.scale-400.png b/windows/runner/resources/msix/Square44x44Logo.scale-400.png new file mode 100644 index 00000000..cd04708d Binary files /dev/null and b/windows/runner/resources/msix/Square44x44Logo.scale-400.png differ diff --git a/windows/runner/resources/msix/StoreLogo.png b/windows/runner/resources/msix/StoreLogo.png new file mode 100644 index 00000000..60dc64d0 Binary files /dev/null and b/windows/runner/resources/msix/StoreLogo.png differ diff --git a/windows/runner/resources/msix/StoreLogo.scale-100.png b/windows/runner/resources/msix/StoreLogo.scale-100.png new file mode 100644 index 00000000..60dc64d0 Binary files /dev/null and b/windows/runner/resources/msix/StoreLogo.scale-100.png differ diff --git a/windows/runner/resources/msix/StoreLogo.scale-125.png b/windows/runner/resources/msix/StoreLogo.scale-125.png new file mode 100644 index 00000000..ea46881a Binary files /dev/null and b/windows/runner/resources/msix/StoreLogo.scale-125.png differ diff --git a/windows/runner/resources/msix/StoreLogo.scale-150.png b/windows/runner/resources/msix/StoreLogo.scale-150.png new file mode 100644 index 00000000..efa15bde Binary files /dev/null and b/windows/runner/resources/msix/StoreLogo.scale-150.png differ diff --git a/windows/runner/resources/msix/StoreLogo.scale-200.png b/windows/runner/resources/msix/StoreLogo.scale-200.png new file mode 100644 index 00000000..ef562943 Binary files /dev/null and b/windows/runner/resources/msix/StoreLogo.scale-200.png differ diff --git a/windows/runner/resources/msix/StoreLogo.scale-400.png b/windows/runner/resources/msix/StoreLogo.scale-400.png new file mode 100644 index 00000000..f1badba4 Binary files /dev/null and b/windows/runner/resources/msix/StoreLogo.scale-400.png differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 00000000..4e0b0b17 --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,23 @@ + + + + + + + + + + + + PerMonitorV2 + true + + + + + + + + + diff --git a/windows/runner/single_instance.cpp b/windows/runner/single_instance.cpp new file mode 100644 index 00000000..303739dc --- /dev/null +++ b/windows/runner/single_instance.cpp @@ -0,0 +1,616 @@ +#include "single_instance.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr DWORD kMaximumActivationBytes = 16 * 1024; +constexpr DWORD kActivationConnectTimeoutMilliseconds = 5000; +constexpr wchar_t kPipePrefix[] = L"\\\\.\\pipe\\BusyMax-Activation-"; +constexpr wchar_t kMutexPrefix[] = L"Local\\BusyMax-SingleInstance-"; + +HANDLE ConnectToActivationPipe(const std::wstring& pipe_name) { + const ULONGLONG deadline = + GetTickCount64() + kActivationConnectTimeoutMilliseconds; + while (true) { + HANDLE pipe = CreateFileW(pipe_name.c_str(), GENERIC_READ | GENERIC_WRITE, + 0, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (pipe != INVALID_HANDLE_VALUE) return pipe; + + const DWORD error = GetLastError(); + if (error != ERROR_FILE_NOT_FOUND && error != ERROR_PIPE_BUSY) { + return INVALID_HANDLE_VALUE; + } + const ULONGLONG now = GetTickCount64(); + if (now >= deadline) return INVALID_HANDLE_VALUE; + const DWORD remaining = static_cast(deadline - now); + + if (error == ERROR_PIPE_BUSY) { + if (!WaitNamedPipeW(pipe_name.c_str(), remaining)) { + const DWORD wait_error = GetLastError(); + if (wait_error != ERROR_FILE_NOT_FOUND && + wait_error != ERROR_SEM_TIMEOUT) { + return INVALID_HANDLE_VALUE; + } + } + } else { + // A newly elected primary owns the mutex before its listener thread has + // created the first pipe instance. Retry that short startup window. + Sleep(std::min(remaining, 25)); + } + } +} + +bool ReadExact(HANDLE pipe, void* destination, DWORD bytes) { + auto* cursor = static_cast(destination); + DWORD total = 0; + while (total < bytes) { + DWORD read = 0; + if (!ReadFile(pipe, cursor + total, bytes - total, &read, nullptr) || + read == 0) { + return false; + } + total += read; + } + return true; +} + +bool WriteExact(HANDLE pipe, const void* source, DWORD bytes) { + const auto* cursor = static_cast(source); + DWORD total = 0; + while (total < bytes) { + DWORD written = 0; + if (!WriteFile(pipe, cursor + total, bytes - total, &written, nullptr) || + written == 0) { + return false; + } + total += written; + } + return true; +} + +bool ReadAcknowledgmentWithTimeout(HANDLE pipe, uint8_t* acknowledgment) { + if (acknowledgment == nullptr) return false; + const ULONGLONG deadline = + GetTickCount64() + kActivationConnectTimeoutMilliseconds; + while (GetTickCount64() < deadline) { + DWORD available = 0; + if (!PeekNamedPipe(pipe, nullptr, 0, nullptr, &available, nullptr)) { + return false; + } + if (available >= sizeof(*acknowledgment)) { + return ReadExact(pipe, acknowledgment, sizeof(*acknowledgment)); + } + Sleep(10); + } + return false; +} + +std::wstring CurrentUserSid() { + HANDLE token = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return L""; + DWORD length = 0; + if (GetTokenInformation(token, TokenUser, nullptr, 0, &length) || + GetLastError() != ERROR_INSUFFICIENT_BUFFER || length == 0) { + CloseHandle(token); + return L""; + } + std::vector buffer(length, 0); + if (!GetTokenInformation(token, TokenUser, buffer.data(), length, &length)) { + CloseHandle(token); + return L""; + } + CloseHandle(token); + const auto* user = reinterpret_cast(buffer.data()); + LPWSTR sid = nullptr; + if (!ConvertSidToStringSidW(user->User.Sid, &sid)) return L""; + std::wstring value(sid); + LocalFree(sid); + return value; +} + +BusyMaxSingleInstanceNativeHooks DefaultHooks() { + BusyMaxSingleInstanceNativeHooks hooks; + hooks.current_user_sid = CurrentUserSid; + hooks.create_mutex = [](const wchar_t* name) { + return CreateMutexW(nullptr, FALSE, name); + }; + hooks.last_error = [] { return GetLastError(); }; + hooks.create_pipe = [](const wchar_t* name, SECURITY_ATTRIBUTES* attributes) { + return CreateNamedPipeW( + name, PIPE_ACCESS_DUPLEX, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | + PIPE_REJECT_REMOTE_CLIENTS, + 1, sizeof(uint8_t), kMaximumActivationBytes + sizeof(uint32_t), 5000, + attributes); + }; + hooks.close_handle = [](HANDLE handle) { CloseHandle(handle); }; + return hooks; +} + +std::string EscapeJson(const std::string& value) { + std::string result; + result.reserve(value.size() + 8); + for (const unsigned char character : value) { + switch (character) { + case '\\': + result += "\\\\"; + break; + case '"': + result += "\\\""; + break; + case '\n': + result += "\\n"; + break; + case '\r': + result += "\\r"; + break; + case '\t': + result += "\\t"; + break; + default: + if (character < 0x20) return ""; + result.push_back(static_cast(character)); + } + } + return result; +} + +bool EndsWithIcs(const std::string& value) { + if (value.size() < 4) return false; + std::string suffix = value.substr(value.size() - 4); + std::transform(suffix.begin(), suffix.end(), suffix.begin(), + [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + return suffix == ".ics"; +} + +bool IsValidIcsPath(const std::string& value) { + if (!EndsWithIcs(value) || value.size() > 32767 || value.empty()) { + return false; + } + for (const unsigned char character : value) { + if (character < 0x20 || character == '"' || character == '*' || + character == '<' || character == '>' || character == '|') { + return false; + } + } + const size_t colon = value.find(':'); + if (colon == std::string::npos) return true; + const bool drive_path = + colon == 1 && std::isalpha(static_cast(value[0])); + const bool extended_drive_path = + colon == 5 && value.compare(0, 4, R"(\\?\)") == 0 && + std::isalpha(static_cast(value[4])); + return drive_path || extended_drive_path; +} + +bool StartsWithWebcal(const std::string& value) { + if (value.size() < 9) return false; + std::string scheme = value.substr(0, 9); + std::transform(scheme.begin(), scheme.end(), scheme.begin(), + [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + return scheme == "webcal://"; +} + +bool IsValidWebcalUri(const std::string& value) { + if (!StartsWithWebcal(value) || value.size() > 8192 || + value.find('#') != std::string::npos || + value.find('\\') != std::string::npos) { + return false; + } + for (size_t index = 0; index < value.size(); ++index) { + const unsigned char character = value[index]; + if (character <= 0x20 || character == 0x7f) return false; + if (character == '%') { + if (index + 2 >= value.size() || + !std::isxdigit(static_cast(value[index + 1])) || + !std::isxdigit(static_cast(value[index + 2]))) { + return false; + } + index += 2; + } + } + constexpr size_t kSchemeLength = 9; + const size_t authority_end = value.find_first_of("/?", kSchemeLength); + const std::string authority = value.substr( + kSchemeLength, authority_end == std::string::npos + ? std::string::npos + : authority_end - kSchemeLength); + return !authority.empty() && authority.find('@') == std::string::npos && + authority.find('\\') == std::string::npos; +} + +bool ValidUtf8(const std::string& value) { + if (value.empty()) return false; + return MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), nullptr, 0) > 0; +} + +bool DecodeJsonString(const std::string& encoded, std::string* decoded) { + decoded->clear(); + decoded->reserve(encoded.size()); + for (size_t index = 0; index < encoded.size(); ++index) { + const unsigned char character = encoded[index]; + if (character < 0x20 || character == '"') return false; + if (character != '\\') { + decoded->push_back(static_cast(character)); + continue; + } + if (++index >= encoded.size()) return false; + switch (encoded[index]) { + case '\\': + case '"': + case '/': + decoded->push_back(encoded[index]); + break; + case 'b': + decoded->push_back('\b'); + break; + case 'f': + decoded->push_back('\f'); + break; + case 'n': + decoded->push_back('\n'); + break; + case 'r': + decoded->push_back('\r'); + break; + case 't': + decoded->push_back('\t'); + break; + default: + // BusyMax's encoder preserves UTF-8 directly and never emits \u + // escapes, so accepting them would unnecessarily widen the IPC + // language beyond messages this runner can produce. + return false; + } + } + return ValidUtf8(*decoded) && decoded->find('\0') == std::string::npos; +} + +bool DecodeValueActivation(const std::string& activation, + const std::string& prefix, + std::string* decoded) { + constexpr char kSuffix[] = "\"}"; + if (activation.size() < prefix.size() + 2 || + activation.compare(0, prefix.size(), prefix) != 0 || + activation.compare(activation.size() - 2, 2, kSuffix) != 0) { + return false; + } + return DecodeJsonString( + activation.substr(prefix.size(), activation.size() - prefix.size() - 2), + decoded); +} + +bool ParseJsonString(const std::string& json, size_t* cursor, + std::string* decoded) { + if (*cursor >= json.size() || json[*cursor] != '"') return false; + const size_t start = ++(*cursor); + while (*cursor < json.size()) { + const unsigned char character = json[*cursor]; + if (character == '"') { + const auto encoded = json.substr(start, *cursor - start); + ++(*cursor); + return DecodeJsonString(encoded, decoded); + } + if (character == '\\') { + ++(*cursor); + if (*cursor >= json.size()) return false; + } else if (character < 0x20) { + return false; + } + ++(*cursor); + } + return false; +} + +bool IsValidNotificationActivation(const std::string& activation) { + constexpr char kPrefix[] = + R"({"version":1,"kind":"notification","action":)"; + constexpr char kPayloadPrefix[] = R"(,"payload":{)"; + if (activation.compare(0, sizeof(kPrefix) - 1, kPrefix) != 0) return false; + + size_t cursor = sizeof(kPrefix) - 1; + std::string action; + if (!ParseJsonString(activation, &cursor, &action) || + (action != "default" && action != "open" && action != "snooze" && + action != "dismiss") || + activation.compare(cursor, sizeof(kPayloadPrefix) - 1, + kPayloadPrefix) != 0) { + return false; + } + cursor += sizeof(kPayloadPrefix) - 1; + + std::set keys; + while (cursor < activation.size() && activation[cursor] != '}') { + std::string key; + std::string value; + if (!ParseJsonString(activation, &cursor, &key) || + cursor >= activation.size() || activation[cursor++] != ':' || + !ParseJsonString(activation, &cursor, &value) || value.empty() || + value.size() > 2048 || + (key != "notificationScheduleId" && key != "itemKind" && + key != "accountId" && key != "sourceId" && key != "itemId") || + !keys.insert(key).second) { + return false; + } + if (cursor < activation.size() && activation[cursor] == ',') { + ++cursor; + if (cursor >= activation.size() || activation[cursor] == '}') { + return false; + } + } else { + break; + } + } + return keys.find("notificationScheduleId") != keys.end() && + cursor + 2 == activation.size() && activation[cursor] == '}' && + activation[cursor + 1] == '}'; +} + +} // namespace + +BusyMaxSingleInstanceNativeHooks DefaultBusyMaxSingleInstanceNativeHooks() { + return DefaultHooks(); +} + +BusyMaxSingleInstance::BusyMaxSingleInstance() + : BusyMaxSingleInstance(DefaultBusyMaxSingleInstanceNativeHooks()) {} + +BusyMaxSingleInstance::BusyMaxSingleInstance( + BusyMaxSingleInstanceNativeHooks hooks) + : hooks_(std::move(hooks)) { + if (!hooks_.current_user_sid || !hooks_.create_mutex || + !hooks_.last_error || !hooks_.create_pipe || !hooks_.close_handle) { + initialization_error_ = "single-instance native hooks are incomplete"; + return; + } + user_sid_ = hooks_.current_user_sid(); + if (user_sid_.empty()) { + initialization_error_ = "current-user SID lookup failed"; + return; + } + pipe_name_ = std::wstring(kPipePrefix) + user_sid_; + const std::wstring mutex_name = std::wstring(kMutexPrefix) + user_sid_; + mutex_ = hooks_.create_mutex(mutex_name.c_str()); + const DWORD mutex_error = hooks_.last_error(); + if (mutex_ == nullptr) { + initialization_error_ = "per-user mutex creation failed"; + return; + } + state_ = mutex_error == ERROR_ALREADY_EXISTS + ? BusyMaxInstanceState::kSecondary + : BusyMaxInstanceState::kPrimary; +} + +BusyMaxSingleInstance::~BusyMaxSingleInstance() { + Stop(); + if (mutex_ != nullptr) hooks_.close_handle(mutex_); +} + +BusyMaxInstanceState BusyMaxSingleInstance::state() const { return state_; } + +bool BusyMaxSingleInstance::IsPrimary() const { + return state_ == BusyMaxInstanceState::kPrimary; +} + +const std::string& BusyMaxSingleInstance::initialization_error() const { + return initialization_error_; +} + +bool BusyMaxSingleInstance::ForwardActivation( + const std::string& activation) const { + if (state_ != BusyMaxInstanceState::kSecondary || + !IsValidBusyMaxActivation(activation)) { + return false; + } + HANDLE pipe = ConnectToActivationPipe(pipe_name_); + if (pipe == INVALID_HANDLE_VALUE) return false; + ULONG server_process_id = 0; + if (GetNamedPipeServerProcessId(pipe, &server_process_id)) { + AllowSetForegroundWindow(server_process_id); + } + const uint32_t length = static_cast(activation.size()); + const bool written = WriteExact(pipe, &length, sizeof(length)) && + WriteExact(pipe, activation.data(), length); + uint8_t acknowledgment = 0; + const bool acknowledged = + written && ReadAcknowledgmentWithTimeout(pipe, &acknowledgment); + hooks_.close_handle(pipe); + return acknowledged && acknowledgment == 1; +} + +bool BusyMaxSingleInstance::Start( + std::function on_activation, + std::function on_listener_failure) { + if (state_ != BusyMaxInstanceState::kPrimary || listener_.joinable()) { + return false; + } + stopping_ = false; + { + std::scoped_lock lock(listener_start_mutex_); + listener_start_complete_ = false; + listener_start_succeeded_ = false; + listener_start_error_.clear(); + } + on_activation_ = std::move(on_activation); + on_listener_failure_ = std::move(on_listener_failure); + try { + listener_ = std::thread(&BusyMaxSingleInstance::Listen, this); + } catch (const std::system_error&) { + initialization_error_ = "named-pipe listener thread creation failed"; + state_ = BusyMaxInstanceState::kFatalInitializationFailure; + return false; + } + std::unique_lock lock(listener_start_mutex_); + const bool acknowledged = listener_start_condition_.wait_for( + lock, std::chrono::seconds(5), + [this] { return listener_start_complete_; }); + if (!acknowledged) { + initialization_error_ = "named-pipe listener startup timed out"; + lock.unlock(); + Stop(); + state_ = BusyMaxInstanceState::kFatalInitializationFailure; + return false; + } + if (!listener_start_succeeded_) { + initialization_error_ = listener_start_error_; + lock.unlock(); + if (listener_.joinable()) listener_.join(); + state_ = BusyMaxInstanceState::kFatalInitializationFailure; + return false; + } + return true; +} + +void BusyMaxSingleInstance::Stop() { + if (!listener_.joinable()) return; + stopping_ = true; + HANDLE pipe = CreateFileW(pipe_name_.c_str(), GENERIC_READ | GENERIC_WRITE, + 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, + nullptr); + if (pipe != INVALID_HANDLE_VALUE) hooks_.close_handle(pipe); + CancelSynchronousIo(static_cast(listener_.native_handle())); + listener_.join(); +} + +HANDLE BusyMaxSingleInstance::CreateSecuredPipe( + SECURITY_ATTRIBUTES* attributes) const { + return hooks_.create_pipe(pipe_name_.c_str(), attributes); +} + +void BusyMaxSingleInstance::CompleteListenerStartup(bool succeeded, + std::string error) { + { + std::scoped_lock lock(listener_start_mutex_); + if (listener_start_complete_) return; + listener_start_succeeded_ = succeeded; + listener_start_error_ = std::move(error); + listener_start_complete_ = true; + } + listener_start_condition_.notify_one(); +} + +void BusyMaxSingleInstance::Listen() { + const std::wstring sddl = L"D:P(A;;GA;;;" + user_sid_ + L")"; + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.c_str(), SDDL_REVISION_1, &descriptor, nullptr)) { + CompleteListenerStartup(false, + "named-pipe security descriptor creation failed"); + return; + } + SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), descriptor, + FALSE}; + HANDLE pipe = CreateSecuredPipe(&attributes); + if (pipe == INVALID_HANDLE_VALUE) { + LocalFree(descriptor); + CompleteListenerStartup(false, "secured named-pipe creation failed"); + return; + } + CompleteListenerStartup(true, ""); + while (!stopping_) { + const bool connected = ConnectNamedPipe(pipe, nullptr) || + GetLastError() == ERROR_PIPE_CONNECTED; + uint8_t acknowledgment = 0; + if (connected && !stopping_) { + uint32_t length = 0; + if (ReadExact(pipe, &length, sizeof(length)) && length > 0 && + length <= kMaximumActivationBytes) { + std::string message(length, '\0'); + if (ReadExact(pipe, message.data(), length) && + IsValidBusyMaxActivation(message)) { + if (on_activation_) { + on_activation_(std::move(message)); + acknowledgment = 1; + } + } + } + WriteExact(pipe, &acknowledgment, sizeof(acknowledgment)); + FlushFileBuffers(pipe); + } + DisconnectNamedPipe(pipe); + hooks_.close_handle(pipe); + pipe = INVALID_HANDLE_VALUE; + if (!stopping_) { + pipe = CreateSecuredPipe(&attributes); + if (pipe == INVALID_HANDLE_VALUE) { + MessageBoxW(nullptr, + L"BusyMax lost its secured activation channel. Close " + L"BusyMax before starting it again.", + L"BusyMax activation error", MB_OK | MB_ICONERROR); + if (on_listener_failure_) on_listener_failure_(); + break; + } + } + } + if (pipe != INVALID_HANDLE_VALUE) hooks_.close_handle(pipe); + LocalFree(descriptor); +} + +std::string BuildBusyMaxActivation( + const std::vector& arguments) { + for (const auto& argument : arguments) { + // The notification plugin is a local COM server. Keep its cold-start + // process hidden until the validated callback tells Dart whether the + // selected action actually requires visible UI. + if (argument == "--start-minimized" || + argument == "----AppNotificationActivationServer") { + return R"({"version":1,"kind":"startMinimized"})"; + } + } + for (const auto& argument : arguments) { + if (IsValidIcsPath(argument)) { + const auto escaped = EscapeJson(argument); + if (!escaped.empty()) { + return "{\"version\":1,\"kind\":\"icsFile\",\"value\":\"" + + escaped + "\"}"; + } + } + if (IsValidWebcalUri(argument)) { + const auto escaped = EscapeJson(argument); + if (!escaped.empty()) { + return "{\"version\":1,\"kind\":\"webCal\",\"value\":\"" + + escaped + "\"}"; + } + } + } + return R"({"version":1,"kind":"normalLaunch"})"; +} + +bool IsValidBusyMaxActivation(const std::string& activation) { + if (activation.empty() || activation.size() > kMaximumActivationBytes || + !ValidUtf8(activation)) { + return false; + } + if (activation == R"({"version":1,"kind":"normalLaunch"})" || + activation == R"({"version":1,"kind":"startMinimized"})") { + return true; + } + std::string value; + if (DecodeValueActivation( + activation, R"({"version":1,"kind":"icsFile","value":")", + &value)) { + return IsValidIcsPath(value); + } + if (DecodeValueActivation( + activation, R"({"version":1,"kind":"webCal","value":")", + &value)) { + return IsValidWebcalUri(value); + } + return IsValidNotificationActivation(activation); +} diff --git a/windows/runner/single_instance.h b/windows/runner/single_instance.h new file mode 100644 index 00000000..3aa8a8a9 --- /dev/null +++ b/windows/runner/single_instance.h @@ -0,0 +1,74 @@ +#ifndef RUNNER_SINGLE_INSTANCE_H_ +#define RUNNER_SINGLE_INSTANCE_H_ + +#include + +#include +#include +#include +#include +#include +#include +#include + +enum class BusyMaxInstanceState { + kPrimary, + kSecondary, + kFatalInitializationFailure, +}; + +struct BusyMaxSingleInstanceNativeHooks { + std::function current_user_sid; + std::function create_mutex; + std::function last_error; + std::function create_pipe; + std::function close_handle; +}; + +BusyMaxSingleInstanceNativeHooks DefaultBusyMaxSingleInstanceNativeHooks(); + +class BusyMaxSingleInstance { + public: + BusyMaxSingleInstance(); + explicit BusyMaxSingleInstance(BusyMaxSingleInstanceNativeHooks hooks); + ~BusyMaxSingleInstance(); + + BusyMaxSingleInstance(const BusyMaxSingleInstance&) = delete; + BusyMaxSingleInstance& operator=(const BusyMaxSingleInstance&) = delete; + + BusyMaxInstanceState state() const; + bool IsPrimary() const; + const std::string& initialization_error() const; + bool ForwardActivation(const std::string& activation) const; + bool Start(std::function on_activation, + std::function on_listener_failure = {}); + void Stop(); + + private: + void Listen(); + HANDLE CreateSecuredPipe(SECURITY_ATTRIBUTES* attributes) const; + void CompleteListenerStartup(bool succeeded, std::string error); + + HANDLE mutex_ = nullptr; + std::wstring pipe_name_; + std::wstring user_sid_; + BusyMaxInstanceState state_ = + BusyMaxInstanceState::kFatalInitializationFailure; + std::string initialization_error_; + BusyMaxSingleInstanceNativeHooks hooks_; + std::atomic stopping_ = false; + std::function on_activation_; + std::function on_listener_failure_; + std::thread listener_; + std::mutex listener_start_mutex_; + std::condition_variable listener_start_condition_; + bool listener_start_complete_ = false; + bool listener_start_succeeded_ = false; + std::string listener_start_error_; +}; + +std::string BuildBusyMaxActivation( + const std::vector& arguments); +bool IsValidBusyMaxActivation(const std::string& activation); + +#endif // RUNNER_SINGLE_INSTANCE_H_ diff --git a/windows/runner/tests/single_instance_test.cpp b/windows/runner/tests/single_instance_test.cpp new file mode 100644 index 00000000..772dde70 --- /dev/null +++ b/windows/runner/tests/single_instance_test.cpp @@ -0,0 +1,181 @@ +#include "../single_instance.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int failures = 0; + +void Check(bool condition, const char* description) { + if (condition) return; + ++failures; + std::cerr << "FAILED: " << description << std::endl; +} + +void TestActivationValidation() { + Check(BuildBusyMaxActivation({"----AppNotificationActivationServer"}) == + R"({"version":1,"kind":"startMinimized"})", + "notification forwarder starts hidden without opening application data"); + Check(IsValidBusyMaxActivation( + R"({"version":1,"kind":"normalLaunch"})"), + "normal activation"); + Check(IsValidBusyMaxActivation( + R"({"version":1,"kind":"icsFile","value":"C:\\Temp\\a.ics"})"), + "ICS activation"); + Check(!IsValidBusyMaxActivation( + R"({"version":1,"kind":"icsFile","value":"file:///C:/Temp/a.ics"})"), + "file URI is not accepted as an ICS path"); + Check(IsValidBusyMaxActivation( + R"({"version":1,"kind":"webCal","value":"webcal://example.test/a"})"), + "webcal activation"); + Check(!IsValidBusyMaxActivation( + R"({"version":1,"kind":"webCal","value":"webcal://user@example.test/a"})"), + "webcal user information rejected"); + Check(!IsValidBusyMaxActivation( + R"({"version":1,"kind":"webCal","value":"webcal://example.test/%zz"})"), + "malformed webcal percent encoding rejected"); + Check(BuildBusyMaxActivation({"webcal://example.test/feed.ics"}) == + R"({"version":1,"kind":"webCal","value":"webcal://example.test/feed.ics"})", + "webcal URI ending in ICS is not misclassified as a file path"); + Check(!IsValidBusyMaxActivation(std::string("\xC3\x28", 2)), + "malformed UTF-8 rejected"); + Check(!IsValidBusyMaxActivation("not-json"), "malformed JSON rejected"); + Check(!IsValidBusyMaxActivation(std::string(16 * 1024 + 1, 'x')), + "oversized activation rejected"); + Check(IsValidBusyMaxActivation( + R"({"version":1,"kind":"notification","action":"snooze","payload":{"notificationScheduleId":"row-1","itemId":"item-1"}})"), + "notification action allowlist accepted"); + Check(!IsValidBusyMaxActivation( + R"({"version":1,"kind":"notification","action":"run-command","payload":{"notificationScheduleId":"row-1"}})"), + "unknown notification action rejected"); +} + +void TestFatalInitializationStates() { + auto sid_failure = DefaultBusyMaxSingleInstanceNativeHooks(); + sid_failure.current_user_sid = [] { return std::wstring(); }; + BusyMaxSingleInstance no_sid(std::move(sid_failure)); + Check(no_sid.state() == + BusyMaxInstanceState::kFatalInitializationFailure, + "SID failure is fatal"); + + auto mutex_failure = DefaultBusyMaxSingleInstanceNativeHooks(); + mutex_failure.create_mutex = [](const wchar_t*) -> HANDLE { return nullptr; }; + mutex_failure.last_error = [] { return ERROR_ACCESS_DENIED; }; + BusyMaxSingleInstance no_mutex(std::move(mutex_failure)); + Check(no_mutex.state() == + BusyMaxInstanceState::kFatalInitializationFailure, + "mutex failure is fatal"); +} + +void TestListenerStartupAcknowledgmentFailure() { + auto hooks = DefaultBusyMaxSingleInstanceNativeHooks(); + hooks.create_pipe = [](const wchar_t*, SECURITY_ATTRIBUTES*) { + SetLastError(ERROR_ACCESS_DENIED); + return INVALID_HANDLE_VALUE; + }; + BusyMaxSingleInstance instance(std::move(hooks)); + if (instance.state() != BusyMaxInstanceState::kPrimary) { + // A concurrently running BusyMax owns the real per-user mutex; this case is + // covered by the installed-package integration harness instead. + return; + } + Check(!instance.Start([](std::string) {}), + "listener startup failure is acknowledged"); + Check(instance.state() == + BusyMaxInstanceState::kFatalInitializationFailure, + "listener startup failure becomes fatal"); +} + +void TestSimultaneousStartAndPipeAcknowledgment() { + BusyMaxSingleInstance primary; + if (primary.state() != BusyMaxInstanceState::kPrimary) return; + std::atomic activations = 0; + Check(primary.Start([&activations](std::string activation) { + if (IsValidBusyMaxActivation(activation)) ++activations; + }), + "primary listener starts before application data"); + + BusyMaxSingleInstance secondary; + Check(secondary.state() == BusyMaxInstanceState::kSecondary, + "simultaneous process is secondary"); + Check(secondary.ForwardActivation( + R"({"version":1,"kind":"normalLaunch"})"), + "secondary receives pipe acknowledgment"); + const ULONGLONG deadline = GetTickCount64() + 1000; + while (activations.load() == 0 && GetTickCount64() < deadline) Sleep(5); + Check(activations.load() == 1, "primary receives one activation"); + + primary.Stop(); + Check(!secondary.ForwardActivation( + R"({"version":1,"kind":"normalLaunch"})"), + "terminating primary does not permit a second database writer"); +} + +void TestForwardingTimeoutIsBounded() { + const std::wstring sid = + L"BusyMaxNativeTimeoutTest-" + std::to_wstring(GetCurrentProcessId()); + const std::wstring pipe_name = L"\\\\.\\pipe\\BusyMax-Activation-" + sid; + HANDLE server = CreateNamedPipeW( + pipe_name.c_str(), PIPE_ACCESS_DUPLEX, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + 1, 1, 16 * 1024 + sizeof(uint32_t), 5000, nullptr); + Check(server != INVALID_HANDLE_VALUE, "timeout test creates stale pipe"); + if (server == INVALID_HANDLE_VALUE) return; + + std::thread hung_server([server] { + if (ConnectNamedPipe(server, nullptr) || + GetLastError() == ERROR_PIPE_CONNECTED) { + uint32_t length = 0; + DWORD read = 0; + ReadFile(server, &length, sizeof(length), &read, nullptr); + std::vector body(length); + if (length > 0 && length <= 16 * 1024) { + ReadFile(server, body.data(), length, &read, nullptr); + } + // Simulate a stale or terminating primary that accepted the activation + // but never acknowledged it. + Sleep(5500); + } + DisconnectNamedPipe(server); + CloseHandle(server); + }); + + auto hooks = DefaultBusyMaxSingleInstanceNativeHooks(); + hooks.current_user_sid = [sid] { return sid; }; + hooks.create_mutex = [](const wchar_t*) { + return CreateEventW(nullptr, TRUE, FALSE, nullptr); + }; + hooks.last_error = [] { return ERROR_ALREADY_EXISTS; }; + BusyMaxSingleInstance secondary(std::move(hooks)); + const ULONGLONG started = GetTickCount64(); + Check(!secondary.ForwardActivation( + R"({"version":1,"kind":"normalLaunch"})"), + "forwarding without acknowledgment fails"); + const ULONGLONG elapsed = GetTickCount64() - started; + Check(elapsed >= 4000 && elapsed < 8000, + "forwarding acknowledgment timeout is bounded"); + hung_server.join(); +} + +} // namespace + +int main() { + TestActivationValidation(); + TestFatalInitializationStates(); + TestListenerStartupAcknowledgmentFailure(); + TestSimultaneousStartAndPipeAcknowledgment(); + TestForwardingTimeoutIsBounded(); + if (failures == 0) { + std::cout << "BusyMax single-instance native tests passed." << std::endl; + } + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 00000000..3cb71466 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 00000000..3879d547 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 00000000..60608d0f --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 00000000..e901dde6 --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/windows/runner/windows_startup_task.cpp b/windows/runner/windows_startup_task.cpp new file mode 100644 index 00000000..8a46d3e8 --- /dev/null +++ b/windows/runner/windows_startup_task.cpp @@ -0,0 +1,119 @@ +#include "windows_startup_task.h" + +#include +#include +#include +#include + +namespace { + +constexpr wchar_t kStartupTaskId[] = L"BusyMaxStartupTask"; + +bool HasPackageIdentity() { + UINT32 length = 0; + const LONG result = GetCurrentPackageFullName(&length, nullptr); + return result == ERROR_INSUFFICIENT_BUFFER; +} + +std::string StateName( + winrt::Windows::ApplicationModel::StartupTaskState state) { + using State = winrt::Windows::ApplicationModel::StartupTaskState; + switch (state) { + case State::Enabled: + return "enabled"; + case State::Disabled: + return "disabled"; + case State::DisabledByUser: + return "disabledByUser"; + case State::DisabledByPolicy: + return "disabledByPolicy"; + default: + return "unavailable"; + } +} + +} // namespace + +void GetBusyMaxStartupTaskStateAsync(BusyMaxStartupTaskStateCallback callback) { + if (!HasPackageIdentity()) { + callback("unavailable"); + return; + } + try { + auto operation = + winrt::Windows::ApplicationModel::StartupTask::GetAsync(kStartupTaskId); + operation.Completed( + [callback]( + const auto& completed, + winrt::Windows::Foundation::AsyncStatus status) mutable { + if (status != winrt::Windows::Foundation::AsyncStatus::Completed) { + callback("unavailable"); + return; + } + try { + callback(StateName(completed.GetResults().State())); + } catch (...) { + callback("unavailable"); + } + }); + } catch (...) { + callback("unavailable"); + } +} + +void SetBusyMaxStartupTaskEnabledAsync( + bool enabled, + BusyMaxStartupTaskChangeCallback callback) { + if (!HasPackageIdentity()) { + callback(false, "BusyMax is not running with package identity."); + return; + } + try { + auto operation = + winrt::Windows::ApplicationModel::StartupTask::GetAsync(kStartupTaskId); + operation.Completed( + [enabled, callback]( + const auto& completed, + winrt::Windows::Foundation::AsyncStatus status) mutable { + if (status != winrt::Windows::Foundation::AsyncStatus::Completed) { + callback(false, "Windows StartupTask lookup failed."); + return; + } + try { + const auto task = completed.GetResults(); + if (!enabled) { + task.Disable(); + callback(true, ""); + return; + } + auto request = task.RequestEnableAsync(); + request.Completed( + [callback]( + const auto& enabled_operation, + winrt::Windows::Foundation::AsyncStatus enabled_status) { + if (enabled_status != + winrt::Windows::Foundation::AsyncStatus::Completed) { + callback(false, "Windows did not enable the StartupTask."); + return; + } + try { + const auto state = enabled_operation.GetResults(); + callback( + state == winrt::Windows::ApplicationModel:: + StartupTaskState::Enabled, + state == winrt::Windows::ApplicationModel:: + StartupTaskState::Enabled + ? "" + : "Windows did not enable the StartupTask."); + } catch (...) { + callback(false, "Windows StartupTask operation failed."); + } + }); + } catch (...) { + callback(false, "Windows StartupTask operation failed."); + } + }); + } catch (...) { + callback(false, "Windows StartupTask operation failed."); + } +} diff --git a/windows/runner/windows_startup_task.h b/windows/runner/windows_startup_task.h new file mode 100644 index 00000000..0459360d --- /dev/null +++ b/windows/runner/windows_startup_task.h @@ -0,0 +1,16 @@ +#ifndef RUNNER_WINDOWS_STARTUP_TASK_H_ +#define RUNNER_WINDOWS_STARTUP_TASK_H_ + +#include +#include + +using BusyMaxStartupTaskStateCallback = std::function; +using BusyMaxStartupTaskChangeCallback = + std::function; + +void GetBusyMaxStartupTaskStateAsync(BusyMaxStartupTaskStateCallback callback); +void SetBusyMaxStartupTaskEnabledAsync( + bool enabled, + BusyMaxStartupTaskChangeCallback callback); + +#endif // RUNNER_WINDOWS_STARTUP_TASK_H_